From 9b86388b3387eb6677494e91a9fbcb223601cded Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Mon, 8 Jun 2026 16:00:10 -0400 Subject: [PATCH 01/23] Add Auto-FL agent skill importer --- MANIFEST.in | 1 + docs/design/autofl_skill.md | 81 ++ docs/user_guide/nvflare_cli/autofl_skill.rst | 85 ++ docs/user_guide/nvflare_cli/nvflare_cli.rst | 9 + nvflare/agent/__init__.py | 15 + nvflare/agent/skills/__init__.py | 15 + nvflare/agent/skills/autofl/SKILL.md | 70 ++ nvflare/agent/skills/autofl/__init__.py | 15 + nvflare/app_common/autofl/__init__.py | 31 + nvflare/app_common/autofl/job_importer.py | 818 ++++++++++++++++++ nvflare/tool/install_skills.py | 111 ++- setup.py | 1 + tests/unit_test/app_common/autofl/__init__.py | 13 + .../app_common/autofl/job_importer_test.py | 210 +++++ tests/unit_test/tool/install_skills_test.py | 52 ++ 15 files changed, 1521 insertions(+), 6 deletions(-) create mode 100644 docs/design/autofl_skill.md create mode 100644 docs/user_guide/nvflare_cli/autofl_skill.rst create mode 100644 nvflare/agent/__init__.py create mode 100644 nvflare/agent/skills/__init__.py create mode 100644 nvflare/agent/skills/autofl/SKILL.md create mode 100644 nvflare/agent/skills/autofl/__init__.py create mode 100644 nvflare/app_common/autofl/__init__.py create mode 100644 nvflare/app_common/autofl/job_importer.py create mode 100644 tests/unit_test/app_common/autofl/__init__.py create mode 100644 tests/unit_test/app_common/autofl/job_importer_test.py create mode 100644 tests/unit_test/tool/install_skills_test.py diff --git a/MANIFEST.in b/MANIFEST.in index ad27ff3a07..c43f4f3244 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,5 +2,6 @@ include versioneer.py include nvflare/_version.py include nvflare/libs/*.so include nvflare/fuel/utils/*.json +recursive-include nvflare/agent/skills *.md recursive-include nvflare/lighter/templates * recursive-include nvflare/tool/deploy/templates * diff --git a/docs/design/autofl_skill.md b/docs/design/autofl_skill.md new file mode 100644 index 0000000000..798d47a53e --- /dev/null +++ b/docs/design/autofl_skill.md @@ -0,0 +1,81 @@ +# NVFlare Auto-FL Skill Design + +## Summary + +Auto-FL should enter NVFlare as a skill-first product experience. Users select +an official NVFlare Auto-FL skill in a coding agent, point it at an existing +`job.py`, and state the optimization objective, environment, and budget. NVFlare +owns deterministic import, execution truth, policy boundaries, artifacts, and +reproducibility. The agent owns candidate planning, code edits within allowed +paths, experiment comparison, and narrative reporting. + +This avoids introducing a new public Auto-FL command tree while still making +Auto-FL an NVFlare-owned feature. + +## Product Boundary + +The first production-oriented slice includes: + +- A bundled `nvflare-autofl` agent skill. +- A deterministic `job.py` importer that emits reviewable `autofl.yaml`. +- A trust contract in `autofl.yaml` showing extracted facts, unresolved fields, + and allowed edit paths. +- Documentation for using the skill with simulation, POC, and production + environments through existing NVFlare surfaces. + +The first version does not embed or vendor a coding agent, and it does not add a +public Auto-FL command family. + +## Deterministic Import + +The importer parses Python source with `ast`; it does not import or execute +user code. It supports known Recipe and FedJob-style patterns first: + +- Recipe/FedJob constructor and class import. +- `SimEnv`, `PocEnv`, and `ProdEnv` references. +- `train_script` resolution for literal and argparse-derived values. +- Objective metric from user request, `key_metric`, or explicit unresolved + default. +- Fixed-budget fields such as rounds, clients, and candidate budget. +- Common argparse tunables from `job.py` and the resolved train script. + +Unsupported or dynamic fields are carried forward as unresolved review items +instead of being guessed by the importer or the agent. + +## Trust Contract + +Every import result includes: + +- `import`: importer version, source path, source hash, support status, and + confidence. +- `job`: surface, entrypoint, allowed edit paths, train script, and call + arguments with provenance. +- `objective`, `budget`, `environment`, and `search_space`. +- `trust_contract`: extracted facts, unresolved fields, allowed edit paths, and + agent controls. + +The skill must present extracted, unresolved, and allowed sections before it +runs candidates. This is the core product guardrail: NVFlare makes the workflow +reviewable and reproducible; the agent makes it interactive. + +## Execution Model + +The skill uses existing NVFlare execution surfaces: + +- Simulation: run the existing job script with its configured `SimEnv`. +- POC: use startup kits and standard `nvflare job` commands. +- Production: use standard startup-kit authentication, site policy, job submit, + wait, download, and inspection commands. + +Production is a valid optimization environment. The best candidate may later be +submitted or reused through the standard NVFlare job lifecycle; no separate +promotion command is needed. + +## Review Questions + +- Are the supported `job.py` patterns sufficient for an initial prototype? +- Are the `autofl.yaml` fields enough for reproducibility and candidate + comparability? +- Is the skill installer target behavior acceptable, or should NVFlare add a + dedicated generic agent-skill install command in a follow-up? +- Which metric/artifact extraction gaps should become stable NVFlare APIs next? diff --git a/docs/user_guide/nvflare_cli/autofl_skill.rst b/docs/user_guide/nvflare_cli/autofl_skill.rst new file mode 100644 index 0000000000..0aca861eb0 --- /dev/null +++ b/docs/user_guide/nvflare_cli/autofl_skill.rst @@ -0,0 +1,85 @@ +.. _autofl_skill: + +####################### +NVFlare Auto-FL Skill +####################### + +The NVFlare Auto-FL skill is an agent-assisted workflow for optimizing an +existing NVFlare ``job.py``. The user entry point is the coding agent skill: +select the NVFlare Auto-FL skill, point it at a job, and state the objective, +environment, and candidate budget. + +NVFlare does not add a separate public Auto-FL command family for this workflow. +Instead, NVFlare provides the deterministic import, reviewable +``autofl.yaml`` contract, execution substrate, policy boundaries, artifacts, +and reproducibility evidence. The agent plans candidate edits and runs them +through existing NVFlare surfaces. + +Typical Prompt +============== + +.. code-block:: text + + Use the NVFlare Auto-FL skill. + Optimize ./job.py for validation accuracy in simulation with an + 8-candidate budget. + +First Step: Deterministic Import +================================ + +The skill first imports the job without executing user code: + +.. code-block:: shell + + python -m nvflare.app_common.autofl.job_importer ./job.py \ + --metric accuracy \ + --env sim \ + --max-candidates 8 \ + --output autofl.yaml + +The importer parses supported Recipe and FedJob patterns with Python AST +inspection. It extracts known fields into ``autofl.yaml`` and marks unknown or +dynamic fields as unresolved instead of guessing. + +Trust Contract +============== + +Before editing or running candidates, the skill should show the user three +things from ``autofl.yaml``: + +- **Extracted**: recipe or FedJob surface, train script, metric, environment, + fixed budget, tunables, artifact locations, source hash, and importer version. +- **Unresolved**: dynamic defaults, unsupported Python semantics, missing metric + sources, unknown data paths, or low-confidence fields. +- **Allowed**: files the agent may edit, fixed-budget fields it must preserve, + and environment or policy boundaries. + +This makes the workflow feel native and reproducible: NVFlare owns the truth of +what was imported and executed; the agent owns exploration within explicit +constraints. + +Execution +========= + +The skill uses existing NVFlare surfaces after import: + +- Simulation jobs run through the job's configured ``SimEnv``. +- POC and production jobs use the standard startup-kit and ``nvflare job`` + submission, wait, download, and inspection commands. +- Production execution is allowed when the user requests it, but the skill must + not bypass normal startup-kit authentication, site policy, or job submission. + +Supported First Version +======================= + +The first version is intentionally narrow: + +- Supported job surfaces: NVFlare Recipe constructors and FedJob-style scripts. +- Supported import fields: objective metric, fixed budget fields, environment, + train script, allowed edit paths, and common argparse tunables. +- Unsupported or ambiguous custom Python is preserved as unresolved review + fields. + +The default user experience should not require editing ``autofl.yaml``. Users +review it only when the importer reports unresolved fields or when they want to +override the campaign configuration. diff --git a/docs/user_guide/nvflare_cli/nvflare_cli.rst b/docs/user_guide/nvflare_cli/nvflare_cli.rst index d1c035837a..205a9535f7 100644 --- a/docs/user_guide/nvflare_cli/nvflare_cli.rst +++ b/docs/user_guide/nvflare_cli/nvflare_cli.rst @@ -47,6 +47,14 @@ Deprecated commands still exposed in help, such as ``simulator`` and ``authz_preview``, are documented only briefly and should not be used for new workflows unless you are maintaining an older setup. +Agent-assisted workflows +======================== + +The :ref:`NVFlare Auto-FL skill ` is documented with CLI +workflows because it uses NVFlare's existing job, POC, production, and +machine-readable output surfaces. It is not a separate ``nvflare`` command +group. + .. toctree:: :maxdepth: 1 @@ -62,5 +70,6 @@ workflows unless you are maintaining an older setup. cert_command package_command recipe_command + autofl_skill preflight_check dashboard_command diff --git a/nvflare/agent/__init__.py b/nvflare/agent/__init__.py new file mode 100644 index 0000000000..38db1a3ea2 --- /dev/null +++ b/nvflare/agent/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Packaged agent-facing NVFlare assets.""" diff --git a/nvflare/agent/skills/__init__.py b/nvflare/agent/skills/__init__.py new file mode 100644 index 0000000000..7b87bb756c --- /dev/null +++ b/nvflare/agent/skills/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bundled NVFlare skills for coding-agent workflows.""" diff --git a/nvflare/agent/skills/autofl/SKILL.md b/nvflare/agent/skills/autofl/SKILL.md new file mode 100644 index 0000000000..3e18246e0c --- /dev/null +++ b/nvflare/agent/skills/autofl/SKILL.md @@ -0,0 +1,70 @@ +--- +name: nvflare-autofl +description: Optimize an existing NVFlare job through an agent-assisted Auto-FL workflow. Use when a user asks to improve accuracy, AUC, loss, runtime, or robustness of an NVFlare job in simulation, POC, or production while preserving NVFlare execution, policy, artifacts, and reproducibility. +--- + +# NVFlare Auto-FL + +Use this skill to optimize an existing NVFlare `job.py` without asking the user +to learn a new Auto-FL command tree. The user selects this skill, points to a +job, and states the objective, environment, and budget. NVFlare provides the +deterministic import, execution substrate, policy boundaries, artifacts, and +machine-readable contracts. The coding agent plans, edits allowed files, runs +candidates, compares results, and reports evidence. + +## Required First Step + +Before editing files, import the job deterministically: + +```bash +python -m nvflare.app_common.autofl.job_importer ./job.py --metric --env --max-candidates --output autofl.yaml +``` + +Read `autofl.yaml` and show the user a concise trust summary: + +- **Extracted**: recipe or FedJob surface, train script, metric, environment, + fixed budget, tunables, artifact locations, and provenance. +- **Unresolved**: dynamic defaults, unsupported Python semantics, missing + metric sources, unknown data paths, or any low-confidence fields. +- **Allowed**: files the agent may edit, fixed-budget fields it must preserve, + and policy boundaries for the requested environment. + +If `autofl.yaml` contains unresolved fields that affect execution safety, +candidate comparability, or production submission, ask the user to resolve those +specific fields before running candidates. + +## Operating Rules + +- Do not edit outside `job.allowed_edit_paths`. +- Preserve `budget.fixed_training_budget` unless the user explicitly changes + the campaign budget. +- Use NVFlare's existing execution surfaces: + - For simulation, run the imported job with its configured `SimEnv`. + - For POC and production, use standard `nvflare job submit`, `job wait`, + `job download`, and related job/status commands. +- Record every candidate with a short name, changed files, diff summary, + run command, metric result, artifacts, and failure reason when applicable. +- Prefer small, reviewable edits over broad rewrites. +- Treat production as an available execution environment, but never bypass + startup-kit authentication, site policy, or normal NVFlare job submission. + +## Candidate Loop + +1. Inspect `autofl.yaml`, the allowed files, and the current job behavior. +2. Propose a candidate change tied to one or more supported tunables or files. +3. Edit only allowed files. +4. Validate the job can still be imported and the fixed budget still matches. +5. Run the candidate through NVFlare in the requested environment. +6. Extract the requested metric from NVFlare artifacts/logs. +7. Update the candidate ledger and keep the best reproducible candidate. +8. Stop when the budget is exhausted, the user stops the run, or results plateau. + +## Final Report + +End with: + +- Best candidate and metric improvement. +- Baseline versus candidate leaderboard. +- Files changed and why. +- Artifacts and commands needed to reproduce the best result. +- Unresolved limitations or production review items. diff --git a/nvflare/agent/skills/autofl/__init__.py b/nvflare/agent/skills/autofl/__init__.py new file mode 100644 index 0000000000..2ec26a6582 --- /dev/null +++ b/nvflare/agent/skills/autofl/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""NVFlare Auto-FL agent skill package.""" diff --git a/nvflare/app_common/autofl/__init__.py b/nvflare/app_common/autofl/__init__.py new file mode 100644 index 0000000000..42fef0858f --- /dev/null +++ b/nvflare/app_common/autofl/__init__.py @@ -0,0 +1,31 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Auto-FL utilities for agent-assisted NVFlare job optimization.""" + +__all__ = [ + "AUTOFL_CONFIG_SCHEMA_VERSION", + "DeterministicJobImporter", + "JobImportError", + "dump_autofl_yaml", + "import_job_to_autofl_config", +] + + +def __getattr__(name): + if name in __all__: + from nvflare.app_common.autofl import job_importer + + return getattr(job_importer, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/nvflare/app_common/autofl/job_importer.py b/nvflare/app_common/autofl/job_importer.py new file mode 100644 index 0000000000..d15e060dde --- /dev/null +++ b/nvflare/app_common/autofl/job_importer.py @@ -0,0 +1,818 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Deterministically import supported NVFlare job scripts into ``autofl.yaml``. + +The Auto-FL skill uses this module as its trust layer. The importer parses +Python source with ``ast``; it never imports or executes the user's ``job.py``. +Supported Recipe/FedJob patterns are converted into a reviewable config, while +dynamic or unsupported fields are surfaced under ``unresolved``. +""" + +from __future__ import annotations + +import argparse +import ast +import hashlib +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + +import yaml + +AUTOFL_CONFIG_SCHEMA_VERSION = "nvflare.autofl.config.v1" +IMPORTER_VERSION = "nvflare-autofl-job-importer/v1" + +SUPPORTED_ENV_NAMES = {"PocEnv", "ProdEnv", "SimEnv"} +TUNABLE_ARG_NAMES = { + "aggregation_epochs", + "alpha", + "batch_size", + "cosine_lr_eta_min_factor", + "eval_batch_size", + "epochs", + "fedopt_beta1", + "fedopt_beta2", + "fedopt_tau", + "fedproxloss_mu", + "local_epochs", + "local_train_steps", + "lr", + "max_model_params", + "model_arch", + "momentum", + "num_workers", + "server_lr", + "server_momentum", + "weight_decay", +} + + +@dataclass(frozen=True) +class ArgSpec: + """Static argparse field extracted from source.""" + + name: str + flags: Tuple[str, ...] + default: Any = None + default_source: str = "argparse_default" + default_unresolved: bool = False + value_type: Optional[str] = None + choices: Optional[List[Any]] = None + action: Optional[str] = None + + +@dataclass(frozen=True) +class ResolvedValue: + """Resolved expression value plus provenance and confidence.""" + + value: Any + source: str + confidence: str = "high" + unresolved: bool = False + + +@dataclass(frozen=True) +class CallInfo: + """Supported call found in ``job.py`` with source-local resolution context.""" + + name: str + full_name: str + keywords: Dict[str, ast.AST] + assignments: Dict[str, ast.AST] + source: str + function_name: Optional[str] = None + + +class JobImportError(ValueError): + """Raised when the importer cannot read or parse a job file.""" + + +class DeterministicJobImporter: + """Rule-based importer for supported NVFlare Recipe and FedJob scripts.""" + + def __init__(self, workspace_root: Optional[str] = None): + self.workspace_root = Path(workspace_root or ".").resolve() + + def import_job( + self, + job_path: str, + *, + metric: Optional[str] = None, + mode: str = "max", + target_env: Optional[str] = None, + max_candidates: Optional[int] = None, + ) -> Dict[str, Any]: + """Return an ``autofl.yaml``-shaped config for ``job_path``. + + Args: + job_path: Path to ``job.py`` or a directory containing ``job.py``. + metric: Optional optimization metric requested by the user. + mode: ``max`` or ``min`` objective direction. + target_env: Optional target environment, such as ``sim`` or ``prod``. + max_candidates: Optional fixed candidate budget. + + Returns: + A deterministic, YAML-serializable mapping. + """ + + if mode not in {"max", "min"}: + raise JobImportError("mode must be 'max' or 'min'") + + source_path = self._resolve_job_path(job_path) + source_text = source_path.read_text(encoding="utf-8") + try: + tree = ast.parse(source_text, filename=str(source_path)) + except SyntaxError as e: + raise JobImportError(f"failed to parse {source_path}: {e}") from e + + index = _ImportIndex.from_tree(tree, source_text) + job_call = index.first_job_call() + env_call = index.first_env_call() + train_script = self._resolve_train_script(source_path, job_call, index.parser_args, source_text) + train_args = _collect_argparse_args_from_file(train_script) if train_script else {} + unresolved: List[Dict[str, str]] = [] + + if not job_call: + unresolved.append(_unresolved("job.surface", "no supported Recipe or FedJob constructor was found")) + if not train_script: + unresolved.append(_unresolved("job.train_script", "no train_script was found or resolved")) + + metric_name, metric_source, metric_issue = self._resolve_metric(metric, job_call, index.parser_args) + if metric_issue: + unresolved.append(metric_issue) + + budget, budget_issues = self._resolve_budget(max_candidates, job_call, env_call, index.parser_args, source_text) + unresolved.extend(budget_issues) + + allowed_edit_paths = self._allowed_edit_paths(source_path, train_script) + search_space, search_issues = self._suggest_search_space(index.parser_args, train_args) + unresolved.extend(search_issues) + + job_payload = { + "source": self._display_path(source_path), + "surface": _surface_name(job_call), + "entrypoint": "main" if _has_main_entrypoint(tree) else "unresolved", + "allowed_edit_paths": allowed_edit_paths, + } + if job_call: + call_args, call_issues = self._resolved_call_keywords(job_call, index.parser_args, source_text) + unresolved.extend(call_issues) + if _is_recipe_call(job_call): + job_payload.update( + { + "recipe": job_call.name, + "recipe_class": index.imports.get(job_call.name, job_call.full_name), + "recipe_args": call_args, + } + ) + else: + job_payload.update( + { + "fed_job": job_call.name, + "fed_job_class": index.imports.get(job_call.name, job_call.full_name), + "fed_job_args": call_args, + } + ) + if train_script: + job_payload["train_script"] = self._display_path(train_script) + + environment = self._environment_profile(target_env, env_call, index.parser_args, source_text) + if env_call: + env_args, env_issues = self._resolved_call_keywords(env_call, index.parser_args, source_text) + unresolved.extend(env_issues) + environment["discovered"] = {"name": env_call.name, "args": env_args} + + config = { + "schema_version": AUTOFL_CONFIG_SCHEMA_VERSION, + "import": { + "importer_version": IMPORTER_VERSION, + "source": self._display_path(source_path), + "source_sha256": _sha256_text(source_text), + "confidence": _overall_confidence(unresolved, job_call), + "support": { + "status": "supported" if job_call else "partial", + "patterns": _support_patterns(job_call, env_call), + }, + }, + "job": job_payload, + "objective": {"metric": metric_name, "mode": mode, "source": metric_source}, + "budget": budget, + "environment": environment, + "search_space": {"suggested": search_space}, + "artifacts": { + "collect": ["logs", "metrics", "job_config", "candidate_diff", "candidate_manifest"], + "result_root": "autofl_runs", + }, + "trust_contract": { + "extracted": _trust_extracted(job_call, env_call, train_script, budget, metric_name, search_space), + "unresolved": unresolved, + "allowed_edit_paths": allowed_edit_paths, + "agent_controls": { + "must_not_edit_outside_allowed_paths": True, + "must_preserve_fixed_training_budget": bool(budget.get("fixed_training_budget")), + "must_report_candidate_diffs": True, + }, + }, + "unresolved": unresolved, + } + return config + + def dump_yaml(self, config: Dict[str, Any]) -> str: + """Return deterministic YAML for an imported Auto-FL config.""" + + return dump_autofl_yaml(config) + + def _resolve_job_path(self, job_path: str) -> Path: + path = Path(job_path) + if not path.is_absolute(): + path = self.workspace_root / path + if path.is_dir(): + path = path / "job.py" + if not path.exists(): + raise JobImportError(f"job.py not found: {job_path}") + if not path.is_file(): + raise JobImportError(f"job path must be a file or directory containing job.py: {job_path}") + return path.resolve() + + def _display_path(self, path: Path) -> str: + try: + return path.resolve().relative_to(self.workspace_root).as_posix() + except ValueError: + return path.resolve().as_posix() + + def _resolve_train_script( + self, + source_path: Path, + job_call: Optional[CallInfo], + parser_args: Dict[str, ArgSpec], + source_text: str, + ) -> Optional[Path]: + if not job_call: + return _existing_path(source_path.parent / "client.py") + + train_script_node = job_call.keywords.get("train_script") + if not train_script_node: + return _existing_path(source_path.parent / "client.py") + + resolved = _resolve_value(train_script_node, job_call.assignments, parser_args, source_text) + value = resolved.value + if isinstance(value, str): + return _existing_path((source_path.parent / value).resolve()) + return _existing_path(source_path.parent / "client.py") + + def _resolve_metric( + self, + requested_metric: Optional[str], + job_call: Optional[CallInfo], + parser_args: Dict[str, ArgSpec], + ) -> Tuple[str, str, Optional[Dict[str, str]]]: + if requested_metric: + return requested_metric, "user_request", None + if job_call and "key_metric" in job_call.keywords: + resolved = _resolve_value(job_call.keywords["key_metric"], job_call.assignments, parser_args, "") + if isinstance(resolved.value, str) and not resolved.unresolved: + return resolved.value, resolved.source, None + return "accuracy", "default", _unresolved("objective.metric", resolved.source) + if "key_metric" in parser_args and isinstance(parser_args["key_metric"].default, str): + return parser_args["key_metric"].default, "arg:key_metric", None + return ( + "accuracy", + "default", + _unresolved("objective.metric", "metric defaulted to accuracy; validation metric source is unknown"), + ) + + def _resolve_budget( + self, + max_candidates: Optional[int], + job_call: Optional[CallInfo], + env_call: Optional[CallInfo], + parser_args: Dict[str, ArgSpec], + source_text: str, + ) -> Tuple[Dict[str, Any], List[Dict[str, str]]]: + budget: Dict[str, Any] = {} + fixed_training_budget: Dict[str, Any] = {} + unresolved = [] + if max_candidates is not None: + budget["max_candidates"] = max_candidates + + if job_call: + for output_key, job_key in (("num_rounds", "num_rounds"), ("min_clients", "min_clients")): + if job_key in job_call.keywords: + resolved = _resolve_value( + job_call.keywords[job_key], job_call.assignments, parser_args, source_text + ) + if resolved.unresolved: + unresolved.append(_unresolved(f"budget.fixed_training_budget.{output_key}", resolved.source)) + else: + fixed_training_budget[output_key] = resolved.value + + if env_call and env_call.name == "SimEnv" and "num_clients" in env_call.keywords: + resolved = _resolve_value(env_call.keywords["num_clients"], env_call.assignments, parser_args, source_text) + if resolved.unresolved: + unresolved.append(_unresolved("budget.fixed_training_budget.num_clients", resolved.source)) + else: + fixed_training_budget["num_clients"] = resolved.value + + if fixed_training_budget: + budget["fixed_training_budget"] = fixed_training_budget + else: + unresolved.append(_unresolved("budget.fixed_training_budget", "no fixed training budget was resolved")) + return budget, unresolved + + def _environment_profile( + self, + target_env: Optional[str], + env_call: Optional[CallInfo], + parser_args: Dict[str, ArgSpec], + source_text: str, + ) -> Dict[str, Any]: + requested = target_env or (_env_name_to_profile(env_call.name) if env_call else "sim") + environment: Dict[str, Any] = {"requested": requested, "profiles": {}} + if env_call and env_call.name == "SimEnv": + sim_profile: Dict[str, Any] = {} + if "num_clients" in env_call.keywords: + resolved = _resolve_value( + env_call.keywords["num_clients"], env_call.assignments, parser_args, source_text + ) + if not resolved.unresolved: + sim_profile["num_clients"] = resolved.value + environment["profiles"]["sim"] = sim_profile + return environment + + def _allowed_edit_paths(self, source_path: Path, train_script: Optional[Path]) -> List[str]: + candidates = [source_path] + if train_script: + candidates.append(train_script) + for filename in ("model.py", "mutation_schema.yaml", "requirements.txt"): + path = source_path.parent / filename + if path.exists(): + candidates.append(path.resolve()) + return list(dict.fromkeys(self._display_path(path) for path in candidates)) + + def _suggest_search_space( + self, + job_args: Dict[str, ArgSpec], + train_args: Dict[str, ArgSpec], + ) -> Tuple[Dict[str, Any], List[Dict[str, str]]]: + suggested = {} + unresolved = [] + for arg_name, arg_spec in sorted({**job_args, **train_args}.items()): + if arg_name not in TUNABLE_ARG_NAMES: + continue + item = { + "type": arg_spec.value_type or _type_name(arg_spec.default), + "default": arg_spec.default, + "source": f"argparse:{arg_name}", + "confidence": "low" if arg_spec.default_unresolved else "high", + } + if arg_spec.choices: + item["values"] = arg_spec.choices + if arg_spec.default_unresolved: + item["default_source"] = arg_spec.default_source + item["unresolved"] = True + unresolved.append( + _unresolved( + f"search_space.suggested.{arg_name}.default", + f"default is dynamic expression: {arg_spec.default}", + ) + ) + suggested[arg_name] = item + + if not suggested: + unresolved.append(_unresolved("search_space.suggested", "no supported tunable argparse fields were found")) + return suggested, unresolved + + def _resolved_call_keywords( + self, + call_info: CallInfo, + parser_args: Dict[str, ArgSpec], + source_text: str, + ) -> Tuple[Dict[str, Any], List[Dict[str, str]]]: + resolved = {} + unresolved = [] + for key, value_node in sorted(call_info.keywords.items()): + value = _resolve_value(value_node, call_info.assignments, parser_args, source_text) + resolved[key] = { + "value": value.value, + "source": value.source, + "confidence": value.confidence, + } + if value.unresolved: + unresolved.append(_unresolved(f"job.{call_info.name}.{key}", value.source)) + return resolved, unresolved + + +def import_job_to_autofl_config( + job_path: str, + *, + workspace_root: Optional[str] = None, + metric: Optional[str] = None, + mode: str = "max", + target_env: Optional[str] = None, + max_candidates: Optional[int] = None, +) -> Dict[str, Any]: + """Convenience wrapper for deterministic job import.""" + + importer = DeterministicJobImporter(workspace_root=workspace_root) + return importer.import_job( + job_path, + metric=metric, + mode=mode, + target_env=target_env, + max_candidates=max_candidates, + ) + + +def dump_autofl_yaml(config: Dict[str, Any]) -> str: + """Return deterministic YAML for an imported Auto-FL config.""" + + return yaml.dump(config, Dumper=_NoAliasSafeDumper, sort_keys=False) + + +class _NoAliasSafeDumper(yaml.SafeDumper): + def ignore_aliases(self, data): + return True + + +class _ImportIndex(ast.NodeVisitor): + def __init__(self, source_text: str): + self.source_text = source_text + self.imports: Dict[str, str] = {} + self.parser_args: Dict[str, ArgSpec] = {} + self.module_assignments: Dict[str, ast.AST] = {} + self._local_assignments_stack: List[Dict[str, ast.AST]] = [] + self._function_stack: List[str] = [] + self.job_calls: List[CallInfo] = [] + self.env_calls: List[CallInfo] = [] + + @classmethod + def from_tree(cls, tree: ast.AST, source_text: str) -> "_ImportIndex": + index = cls(source_text) + index.visit(tree) + return index + + def first_job_call(self) -> Optional[CallInfo]: + return self.job_calls[0] if self.job_calls else None + + def first_env_call(self) -> Optional[CallInfo]: + return self.env_calls[0] if self.env_calls else None + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + self.imports[alias.asname or alias.name] = alias.name + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if node.module: + for alias in node.names: + self.imports[alias.asname or alias.name] = f"{node.module}.{alias.name}" + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._function_stack.append(node.name) + self._local_assignments_stack.append({}) + self.generic_visit(node) + self._local_assignments_stack.pop() + self._function_stack.pop() + + def visit_Assign(self, node: ast.Assign) -> None: + for target in node.targets: + if isinstance(target, ast.Name): + self._current_assignments()[target.id] = node.value + self.generic_visit(node) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + if isinstance(node.target, ast.Name) and node.value: + self._current_assignments()[node.target.id] = node.value + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + call_name = _call_name(node.func) + if _is_argparse_add_argument_call(call_name): + arg_spec = _arg_spec_from_call(node) + if arg_spec: + self.parser_args[arg_spec.name] = arg_spec + + short_name = call_name.split(".")[-1] + if _is_supported_job_call_name(short_name) or short_name in SUPPORTED_ENV_NAMES: + call_info = CallInfo( + name=short_name, + full_name=call_name, + keywords={keyword.arg: keyword.value for keyword in node.keywords if keyword.arg}, + assignments=self._resolution_assignments(), + source=_source_segment(self.source_text, node), + function_name=self._function_stack[-1] if self._function_stack else None, + ) + if short_name in SUPPORTED_ENV_NAMES: + self.env_calls.append(call_info) + else: + self.job_calls.append(call_info) + self.generic_visit(node) + + def _current_assignments(self) -> Dict[str, ast.AST]: + if self._local_assignments_stack: + return self._local_assignments_stack[-1] + return self.module_assignments + + def _resolution_assignments(self) -> Dict[str, ast.AST]: + assignments = dict(self.module_assignments) + if self._local_assignments_stack: + assignments.update(self._local_assignments_stack[-1]) + return assignments + + +def _collect_argparse_args_from_file(path: Optional[Path]) -> Dict[str, ArgSpec]: + if not path or not path.exists(): + return {} + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except SyntaxError: + return {} + return _ImportIndex.from_tree(tree, "").parser_args + + +def _arg_spec_from_call(node: ast.Call) -> Optional[ArgSpec]: + flags = [] + for arg in node.args: + is_literal, value = _literal_value(arg) + if is_literal and isinstance(value, str) and value.startswith("-"): + flags.append(value) + if not flags: + return None + + keywords = {keyword.arg: keyword.value for keyword in node.keywords if keyword.arg} + name = _literal_keyword_value(keywords.get("dest")) or _name_from_flags(flags) + if not name: + return None + + action = _literal_keyword_value(keywords.get("action")) + default, default_source, default_unresolved = _arg_default_from_keywords(keywords) + if not default_unresolved and default is None and action == "store_true": + default = False + default_source = "argparse_action" + elif not default_unresolved and default is None and action == "store_false": + default = True + default_source = "argparse_action" + + return ArgSpec( + name=name, + flags=tuple(flags), + default=default, + default_source=default_source, + default_unresolved=default_unresolved, + value_type=_call_name(keywords["type"]) if "type" in keywords else None, + choices=_literal_sequence(keywords.get("choices")), + action=action, + ) + + +def _arg_default_from_keywords(keywords: Dict[str, ast.AST]) -> Tuple[Any, str, bool]: + if "default" not in keywords: + return None, "argparse_default", False + + node = keywords["default"] + is_literal, literal = _literal_value(node) + if is_literal: + return literal, "literal", False + return _unparse(node), "expression", True + + +def _name_from_flags(flags: Iterable[str]) -> Optional[str]: + long_flags = [flag for flag in flags if flag.startswith("--")] + selected = long_flags[0] if long_flags else next(iter(flags), None) + if not selected: + return None + return selected.lstrip("-").replace("-", "_") + + +def _resolve_value( + node: ast.AST, + assignments: Dict[str, ast.AST], + parser_args: Dict[str, ArgSpec], + source_text: str, + seen: Optional[set[str]] = None, +) -> ResolvedValue: + seen = seen or set() + is_literal, literal = _literal_value(node) + if is_literal: + return ResolvedValue(literal, "literal") + + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == "args": + arg_spec = parser_args.get(node.attr) + if arg_spec: + return _resolve_arg_default(node.attr, arg_spec) + return ResolvedValue(None, f"unresolved arg:{node.attr}", "low", True) + + if isinstance(node, ast.Name): + if node.id in seen: + return ResolvedValue(None, f"recursive reference:{node.id}", "low", True) + if node.id in parser_args: + return _resolve_arg_default(node.id, parser_args[node.id]) + if node.id in assignments: + return _resolve_value(assignments[node.id], assignments, parser_args, source_text, seen | {node.id}) + return ResolvedValue(node.id, f"name:{node.id}", "medium") + + if isinstance(node, ast.Call): + call_name = _call_name(node.func) + if call_name in {"Path", "pathlib.Path", "os.path.join"}: + arg_value = _first_resolved_argparse_string(node, assignments, parser_args, source_text) + if arg_value is not None: + return arg_value + return ResolvedValue(_source_segment(source_text, node) or call_name, f"call:{call_name}", "medium") + + return ResolvedValue(_source_segment(source_text, node) or type(node).__name__, "expression", "low", True) + + +def _resolve_arg_default(name: str, arg_spec: ArgSpec) -> ResolvedValue: + if arg_spec.default_unresolved: + return ResolvedValue(arg_spec.default, f"arg:{name}:{arg_spec.default_source}", "low", True) + return ResolvedValue(arg_spec.default, f"arg:{name}") + + +def _first_resolved_argparse_string( + node: ast.Call, + assignments: Dict[str, ast.AST], + parser_args: Dict[str, ArgSpec], + source_text: str, +) -> Optional[ResolvedValue]: + for arg in node.args: + resolved = _resolve_value(arg, assignments, parser_args, source_text) + if resolved.source.startswith("arg:") and isinstance(resolved.value, str): + return resolved + return None + + +def _call_name(node: Optional[ast.AST]) -> str: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + parent = _call_name(node.value) + return f"{parent}.{node.attr}" if parent else node.attr + return "" + + +def _literal_value(node: Optional[ast.AST]) -> Tuple[bool, Any]: + if isinstance(node, ast.Constant): + return True, node.value + if isinstance(node, (ast.List, ast.Tuple)): + values = [] + for item in node.elts: + is_literal, value = _literal_value(item) + if not is_literal: + return False, None + values.append(value) + return True, values + return False, None + + +def _literal_keyword_value(node: Optional[ast.AST]) -> Any: + is_literal, value = _literal_value(node) + return value if is_literal else None + + +def _literal_sequence(node: Optional[ast.AST]) -> Optional[List[Any]]: + is_literal, value = _literal_value(node) + return value if is_literal and isinstance(value, list) else None + + +def _source_segment(source_text: str, node: ast.AST) -> str: + if not source_text: + return "" + return ast.get_source_segment(source_text, node) or "" + + +def _unparse(node: ast.AST) -> str: + try: + return ast.unparse(node) + except Exception: + return type(node).__name__ + + +def _is_argparse_add_argument_call(call_name: str) -> bool: + return call_name.endswith(".add_argument") + + +def _is_supported_job_call_name(name: str) -> bool: + return name.endswith("Recipe") or name in {"BaseFedJob", "FedJob"} + + +def _is_recipe_call(call_info: CallInfo) -> bool: + return call_info.name.endswith("Recipe") + + +def _surface_name(call_info: Optional[CallInfo]) -> str: + if not call_info: + return "unknown" + return "recipe" if _is_recipe_call(call_info) else "fed_job" + + +def _env_name_to_profile(env_name: str) -> str: + return env_name.removesuffix("Env").lower() + + +def _support_patterns(job_call: Optional[CallInfo], env_call: Optional[CallInfo]) -> List[str]: + patterns = [] + if job_call: + patterns.append(f"{_surface_name(job_call)}:{job_call.name}") + if env_call: + patterns.append(f"env:{env_call.name}") + return patterns + + +def _trust_extracted( + job_call: Optional[CallInfo], + env_call: Optional[CallInfo], + train_script: Optional[Path], + budget: Dict[str, Any], + metric_name: str, + search_space: Dict[str, Any], +) -> List[Dict[str, Any]]: + extracted = [] + if job_call: + extracted.append({"field": "job.surface", "value": _surface_name(job_call)}) + extracted.append({"field": f"job.{_surface_name(job_call)}", "value": job_call.name}) + if env_call: + extracted.append({"field": "environment.discovered", "value": env_call.name}) + if train_script: + extracted.append({"field": "job.train_script", "value": train_script.name}) + extracted.append({"field": "objective.metric", "value": metric_name}) + if "fixed_training_budget" in budget: + extracted.append({"field": "budget.fixed_training_budget", "value": budget["fixed_training_budget"]}) + if search_space: + extracted.append({"field": "search_space.suggested", "value": sorted(search_space)}) + return extracted + + +def _has_main_entrypoint(tree: ast.AST) -> bool: + return any(isinstance(node, ast.FunctionDef) and node.name == "main" for node in ast.walk(tree)) + + +def _existing_path(path: Path) -> Optional[Path]: + return path.resolve() if path.exists() else None + + +def _sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _type_name(value: Any) -> str: + if isinstance(value, bool): + return "bool" + if isinstance(value, int): + return "int" + if isinstance(value, float): + return "float" + if isinstance(value, str): + return "str" + if value is None: + return "unknown" + return type(value).__name__ + + +def _overall_confidence(unresolved: List[Dict[str, str]], job_call: Optional[CallInfo]) -> str: + if not job_call: + return "low" + return "medium" if unresolved else "high" + + +def _unresolved(field: str, reason: str) -> Dict[str, str]: + return {"field": field, "reason": reason} + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Deterministically import an NVFlare job.py into autofl.yaml") + parser.add_argument("job", help="NVFlare job.py file or directory containing job.py") + parser.add_argument("--output", default="autofl.yaml", help="output path for generated autofl.yaml") + parser.add_argument("--metric", help="requested optimization metric") + parser.add_argument("--mode", default="max", choices=["max", "min"]) + parser.add_argument("--env", dest="target_env", choices=["sim", "poc", "prod"], help="target environment") + parser.add_argument("--max-candidates", type=int, help="candidate budget") + args = parser.parse_args(argv) + + importer = DeterministicJobImporter() + config = importer.import_job( + args.job, + metric=args.metric, + mode=args.mode, + target_env=args.target_env, + max_candidates=args.max_candidates, + ) + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(importer.dump_yaml(config), encoding="utf-8") + print(output_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/nvflare/tool/install_skills.py b/nvflare/tool/install_skills.py index e82c7ccfa6..57c013cfe6 100644 --- a/nvflare/tool/install_skills.py +++ b/nvflare/tool/install_skills.py @@ -12,14 +12,113 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import shutil +from datetime import datetime, timezone +from importlib import resources +from pathlib import Path +from typing import Iterable, Optional -def install_skills(): - """Minimal stub for skill installation. +_SKILL_PACKAGES = { + "nvflare-autofl": "nvflare.agent.skills.autofl", +} - Phase 3 will flesh out this function to install NVFlare CLI skills. - Always returns a summary dict and never raises. + +def available_skills(): + """Return bundled agent skills that can be installed.""" + + return [{"name": name, "package": package} for name, package in sorted(_SKILL_PACKAGES.items())] + + +def install_skills( + target_dir: Optional[str] = None, + skill_names: Optional[Iterable[str]] = None, + dry_run: bool = False, +): + """Install bundled NVFlare agent skills into ``target_dir``. + + The default call remains safe for existing ``poc prepare`` users: if no + target directory is supplied and ``NVFLARE_SKILLS_DIR`` is unset, the + installer records a skip and performs no writes. This function intentionally + returns a summary dict and does not raise, because callers treat skills as an + optional enhancement. + + Args: + target_dir: Destination directory for skill subdirectories. + skill_names: Optional subset of bundled skill names. + dry_run: If True, report planned actions without writing files. Returns: - dict: Summary with 'installed', 'skipped', and 'backed_up' lists. + Summary with ``installed``, ``skipped``, ``backed_up``, and ``errors``. """ - return {"installed": [], "skipped": [], "backed_up": []} + + selected_names = list(skill_names) if skill_names is not None else sorted(_SKILL_PACKAGES) + resolved_target = target_dir or os.environ.get("NVFLARE_SKILLS_DIR") + result = { + "target_dir": resolved_target, + "installed": [], + "skipped": [], + "backed_up": [], + "errors": [], + "dry_run": dry_run, + } + + if not resolved_target: + for name in selected_names: + result["skipped"].append({"name": name, "reason": "no target_dir or NVFLARE_SKILLS_DIR configured"}) + return result + + target = Path(resolved_target).expanduser() + for name in selected_names: + package = _SKILL_PACKAGES.get(name) + if not package: + result["errors"].append({"name": name, "reason": "unknown bundled skill"}) + continue + + destination = target / name + try: + source = resources.files(package) + if _destination_matches_source(source, destination): + result["skipped"].append({"name": name, "path": str(destination), "reason": "already current"}) + continue + + backup_path = None + if destination.exists(): + backup_path = _backup_path(target, name) + if not dry_run: + backup_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(destination), str(backup_path)) + result["backed_up"].append({"name": name, "path": str(backup_path)}) + + if not dry_run: + destination.mkdir(parents=True, exist_ok=True) + _copy_skill_resources(source, destination) + result["installed"].append({"name": name, "path": str(destination)}) + except Exception as e: + result["errors"].append({"name": name, "reason": str(e)}) + return result + + +def _backup_path(target: Path, skill_name: str) -> Path: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f") + return target / ".bak" / timestamp / skill_name + + +def _destination_matches_source(source, destination: Path) -> bool: + source_skill = source / "SKILL.md" + destination_skill = destination / "SKILL.md" + if not destination_skill.exists() or not source_skill.is_file(): + return False + return destination_skill.read_bytes() == source_skill.read_bytes() + + +def _copy_skill_resources(source, destination: Path) -> None: + for child in source.iterdir(): + if child.name == "__pycache__" or child.name.endswith((".py", ".pyc")): + continue + target = destination / child.name + if child.is_dir(): + target.mkdir(parents=True, exist_ok=True) + _copy_skill_resources(child, target) + elif child.is_file(): + target.write_bytes(child.read_bytes()) diff --git a/setup.py b/setup.py index ef2fea3d7a..9af216cf1f 100644 --- a/setup.py +++ b/setup.py @@ -104,6 +104,7 @@ def remove_dir(target_path): ), package_data={ "": ["*.yml", "*.yaml", "*.tpl", "*.html", "*.js", "poc.zip", "*.config", "*.conf"], + "nvflare.agent.skills.autofl": ["*.md"], "nvflare.dashboard.application": extra_files, "nvflare.tool.job": job_templates, "nvflare.tool.deploy": deploy_templates, diff --git a/tests/unit_test/app_common/autofl/__init__.py b/tests/unit_test/app_common/autofl/__init__.py new file mode 100644 index 0000000000..4fc25d0d3c --- /dev/null +++ b/tests/unit_test/app_common/autofl/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unit_test/app_common/autofl/job_importer_test.py b/tests/unit_test/app_common/autofl/job_importer_test.py new file mode 100644 index 0000000000..50e6f6c092 --- /dev/null +++ b/tests/unit_test/app_common/autofl/job_importer_test.py @@ -0,0 +1,210 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import yaml + +from nvflare.app_common.autofl import ( + AUTOFL_CONFIG_SCHEMA_VERSION, + DeterministicJobImporter, + dump_autofl_yaml, + import_job_to_autofl_config, +) + + +def _write_recipe_job(root): + (root / "model.py").write_text( + """ +class SimpleNetwork: + pass +""", + encoding="utf-8", + ) + (root / "client.py").write_text( + """ +import argparse + + +def build_parser(): + parser = argparse.ArgumentParser() + parser.add_argument("--lr", type=float, default=0.01) + parser.add_argument("--batch_size", type=int, default=64) + parser.add_argument("--weight_decay", type=float, default=0.001) + return parser +""", + encoding="utf-8", + ) + (root / "job.py").write_text( + """ +import argparse + +from model import SimpleNetwork +from nvflare.app_opt.pt.recipes.fedavg import FedAvgRecipe +from nvflare.recipe import SimEnv + + +def define_parser(): + parser = argparse.ArgumentParser() + parser.add_argument("--n_clients", type=int, default=3) + parser.add_argument("--num_rounds", type=int, default=5) + parser.add_argument("--train_script", type=str, default="client.py") + parser.add_argument("--key_metric", type=str, default="accuracy") + return parser.parse_args() + + +def main(): + args = define_parser() + recipe = FedAvgRecipe( + name="demo", + min_clients=args.n_clients, + num_rounds=args.num_rounds, + model=SimpleNetwork(), + train_script=args.train_script, + key_metric=args.key_metric, + ) + env = SimEnv(num_clients=args.n_clients) + recipe.execute(env) + + +if __name__ == "__main__": + main() +""", + encoding="utf-8", + ) + return root / "job.py" + + +def test_import_recipe_job_extracts_trust_contract_without_executing_code(tmp_path): + job_path = _write_recipe_job(tmp_path) + + config = import_job_to_autofl_config( + str(job_path), + workspace_root=str(tmp_path), + metric="AUC", + target_env="prod", + max_candidates=8, + ) + + assert config["schema_version"] == AUTOFL_CONFIG_SCHEMA_VERSION + assert config["import"]["support"]["patterns"] == ["recipe:FedAvgRecipe", "env:SimEnv"] + assert config["import"]["confidence"] == "high" + assert config["job"]["surface"] == "recipe" + assert config["job"]["recipe"] == "FedAvgRecipe" + assert config["job"]["train_script"] == "client.py" + assert config["objective"] == {"metric": "AUC", "mode": "max", "source": "user_request"} + assert config["budget"]["max_candidates"] == 8 + assert config["budget"]["fixed_training_budget"] == { + "num_rounds": 5, + "min_clients": 3, + "num_clients": 3, + } + assert config["environment"]["requested"] == "prod" + assert config["environment"]["profiles"]["sim"] == {"num_clients": 3} + assert config["search_space"]["suggested"]["lr"]["default"] == 0.01 + assert config["search_space"]["suggested"]["batch_size"]["type"] == "int" + assert config["trust_contract"]["allowed_edit_paths"] == ["job.py", "client.py", "model.py"] + assert config["trust_contract"]["agent_controls"]["must_not_edit_outside_allowed_paths"] is True + assert config["unresolved"] == [] + + +def test_import_is_repeatable_and_yaml_round_trips(tmp_path): + job_path = _write_recipe_job(tmp_path) + importer = DeterministicJobImporter(workspace_root=str(tmp_path)) + + first = importer.import_job(str(job_path), max_candidates=4) + second = importer.import_job(str(job_path), max_candidates=4) + yaml_text = dump_autofl_yaml(first) + + assert first == second + assert yaml.safe_load(yaml_text) == first + assert "&id" not in yaml_text + + +def test_import_marks_dynamic_argparse_defaults_unresolved(tmp_path): + (tmp_path / "client.py").write_text( + """ +import argparse + + +def build_parser(): + parser = argparse.ArgumentParser() + parser.add_argument("--model_arch", type=str, default=DEFAULT_MODEL_ARCH) + return parser +""", + encoding="utf-8", + ) + job_path = tmp_path / "job.py" + job_path.write_text( + """ +import argparse + +from nvflare.app_opt.pt.recipes.fedavg import FedAvgRecipe +from nvflare.recipe import SimEnv + + +def define_parser(): + parser = argparse.ArgumentParser() + parser.add_argument("--n_clients", type=int, default=2) + parser.add_argument("--num_rounds", type=int, default=3) + parser.add_argument("--train_script", type=str, default="client.py") + return parser.parse_args() + + +def main(): + args = define_parser() + recipe = FedAvgRecipe( + name="demo", + min_clients=args.n_clients, + num_rounds=args.num_rounds, + train_script=args.train_script, + ) + recipe.execute(SimEnv(num_clients=args.n_clients)) +""", + encoding="utf-8", + ) + + config = import_job_to_autofl_config(str(job_path), workspace_root=str(tmp_path)) + + model_arch = config["search_space"]["suggested"]["model_arch"] + assert model_arch["default"] == "DEFAULT_MODEL_ARCH" + assert model_arch["confidence"] == "low" + assert model_arch["unresolved"] is True + assert config["import"]["confidence"] == "medium" + assert { + "field": "search_space.suggested.model_arch.default", + "reason": "default is dynamic expression: DEFAULT_MODEL_ARCH", + } in config["unresolved"] + + +def test_import_marks_unsupported_custom_job_as_partial(tmp_path): + job_path = tmp_path / "job.py" + job_path.write_text( + """ +def main(): + run_custom_workflow() + + +if __name__ == "__main__": + main() +""", + encoding="utf-8", + ) + + config = import_job_to_autofl_config(str(job_path), workspace_root=str(tmp_path)) + + assert config["import"]["support"]["status"] == "partial" + assert config["job"]["surface"] == "unknown" + unresolved_fields = {item["field"] for item in config["unresolved"]} + assert "job.surface" in unresolved_fields + assert "job.train_script" in unresolved_fields + assert "budget.fixed_training_budget" in unresolved_fields diff --git a/tests/unit_test/tool/install_skills_test.py b/tests/unit_test/tool/install_skills_test.py new file mode 100644 index 0000000000..aad1e31a98 --- /dev/null +++ b/tests/unit_test/tool/install_skills_test.py @@ -0,0 +1,52 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from nvflare.tool.install_skills import available_skills, install_skills + + +def test_available_skills_includes_autofl(): + assert {"name": "nvflare-autofl", "package": "nvflare.agent.skills.autofl"} in available_skills() + + +def test_install_skills_without_target_is_safe(monkeypatch): + monkeypatch.delenv("NVFLARE_SKILLS_DIR", raising=False) + + result = install_skills() + + assert result["installed"] == [] + assert result["errors"] == [] + assert result["skipped"] == [{"name": "nvflare-autofl", "reason": "no target_dir or NVFLARE_SKILLS_DIR configured"}] + + +def test_install_skills_copies_bundled_autofl_skill(tmp_path): + result = install_skills(target_dir=str(tmp_path)) + + skill_path = tmp_path / "nvflare-autofl" / "SKILL.md" + assert result["errors"] == [] + assert result["installed"] == [{"name": "nvflare-autofl", "path": str(tmp_path / "nvflare-autofl")}] + assert skill_path.exists() + assert "NVFlare Auto-FL" in skill_path.read_text(encoding="utf-8") + + second = install_skills(target_dir=str(tmp_path)) + assert second["installed"] == [] + assert second["skipped"] == [ + {"name": "nvflare-autofl", "path": str(tmp_path / "nvflare-autofl"), "reason": "already current"} + ] + + +def test_install_skills_dry_run_does_not_write(tmp_path): + result = install_skills(target_dir=str(tmp_path), dry_run=True) + + assert result["installed"] == [{"name": "nvflare-autofl", "path": str(tmp_path / "nvflare-autofl")}] + assert not (tmp_path / "nvflare-autofl").exists() From 5e87fa232ad79192a202887e784ff54fb9d61b7d Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Tue, 9 Jun 2026 15:52:14 -0400 Subject: [PATCH 02/23] Clarify Auto-FL campaign config role --- docs/design/autofl_skill.md | 64 +++++++++++++++----- docs/user_guide/nvflare_cli/autofl_skill.rst | 18 ++++-- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/docs/design/autofl_skill.md b/docs/design/autofl_skill.md index 798d47a53e..f29dae802a 100644 --- a/docs/design/autofl_skill.md +++ b/docs/design/autofl_skill.md @@ -5,9 +5,10 @@ Auto-FL should enter NVFlare as a skill-first product experience. Users select an official NVFlare Auto-FL skill in a coding agent, point it at an existing `job.py`, and state the optimization objective, environment, and budget. NVFlare -owns deterministic import, execution truth, policy boundaries, artifacts, and -reproducibility. The agent owns candidate planning, code edits within allowed -paths, experiment comparison, and narrative reporting. +owns deterministic import of campaign-relevant settings, execution truth, policy +boundaries, artifacts, and reproducibility. The agent owns candidate planning, +code edits within allowed paths, experiment execution through the existing +`job.py`, comparison, and narrative reporting. This avoids introducing a new public Auto-FL command tree while still making Auto-FL an NVFlare-owned feature. @@ -17,19 +18,43 @@ Auto-FL an NVFlare-owned feature. The first production-oriented slice includes: - A bundled `nvflare-autofl` agent skill. -- A deterministic `job.py` importer that emits reviewable `autofl.yaml`. -- A trust contract in `autofl.yaml` showing extracted facts, unresolved fields, - and allowed edit paths. +- A deterministic `job.py` importer that emits reviewable `autofl.yaml` for the + Auto-FL campaign. +- A trust contract in `autofl.yaml` showing editable campaign settings, + unresolved fields, fixed-budget constraints, and allowed edit paths. - Documentation for using the skill with simulation, POC, and production environments through existing NVFlare surfaces. The first version does not embed or vendor a coding agent, and it does not add a public Auto-FL command family. +## Role of autofl.yaml + +`autofl.yaml` is not a replacement for `job.py` and is not a second exported +job format. The original `job.py` remains the experiment entry point the agent +uses to run candidates, and exported job folders remain the NVFlare execution +and submission artifacts. + +The purpose of `autofl.yaml` is to expose the human-reviewable Auto-FL campaign +layer: + +- Objective metric, requested environment, and candidate budget. +- Editable search-space settings discovered from `job.py` and related train + scripts. +- Fixed-budget constraints that must remain comparable across candidates. +- Allowed edit paths and files that are out of scope for the agent. +- Artifact, ledger, and report locations for the campaign. +- Provenance and unresolved fields that need user review before safe execution. + +By default, users should not need to edit `autofl.yaml`. They review or modify +it only when the importer surfaces unresolved settings or when they want to +override campaign knobs explicitly. + ## Deterministic Import The importer parses Python source with `ast`; it does not import or execute -user code. It supports known Recipe and FedJob-style patterns first: +user code. It supports known Recipe and FedJob-style patterns first and focuses +on campaign-relevant settings rather than duplicating the full exported job: - Recipe/FedJob constructor and class import. - `SimEnv`, `PocEnv`, and `ProdEnv` references. @@ -39,6 +64,14 @@ user code. It supports known Recipe and FedJob-style patterns first: - Fixed-budget fields such as rounds, clients, and candidate budget. - Common argparse tunables from `job.py` and the resolved train script. +The exported job folder remains useful as execution truth once the job is +materialized, because it contains resolved NVFlare app and component configs. +However, it does not reliably preserve all authoring intent needed for an +Auto-FL campaign, such as editable source files, train-argument construction, +tunable-versus-fixed intent, and source provenance. Therefore the importer uses +deterministic Python/static parsing for the campaign layer and may use exported +config inspection as a validation aid when available. + Unsupported or dynamic fields are carried forward as unresolved review items instead of being guessed by the importer or the agent. @@ -54,16 +87,17 @@ Every import result includes: - `trust_contract`: extracted facts, unresolved fields, allowed edit paths, and agent controls. -The skill must present extracted, unresolved, and allowed sections before it -runs candidates. This is the core product guardrail: NVFlare makes the workflow -reviewable and reproducible; the agent makes it interactive. +The skill must present editable, unresolved, and allowed sections before it runs +candidates. This is the core product guardrail: NVFlare makes the campaign +reviewable and reproducible; the agent makes it interactive and exploratory. ## Execution Model The skill uses existing NVFlare execution surfaces: -- Simulation: run the existing job script with its configured `SimEnv`. -- POC: use startup kits and standard `nvflare job` commands. +- Simulation: run the existing `job.py` with its configured `SimEnv`. +- POC: use the existing job authoring/export flow, startup kits, and standard + `nvflare job` commands. - Production: use standard startup-kit authentication, site policy, job submit, wait, download, and inspection commands. @@ -74,8 +108,10 @@ promotion command is needed. ## Review Questions - Are the supported `job.py` patterns sufficient for an initial prototype? -- Are the `autofl.yaml` fields enough for reproducibility and candidate - comparability? +- Are the editable `autofl.yaml` fields enough for human-in-the-loop campaign + review and candidate comparability? +- Which exported-job fields should be used as validation evidence versus static + `job.py` parsing for authoring intent? - Is the skill installer target behavior acceptable, or should NVFlare add a dedicated generic agent-skill install command in a follow-up? - Which metric/artifact extraction gaps should become stable NVFlare APIs next? diff --git a/docs/user_guide/nvflare_cli/autofl_skill.rst b/docs/user_guide/nvflare_cli/autofl_skill.rst index 0aca861eb0..f2ed25d6ba 100644 --- a/docs/user_guide/nvflare_cli/autofl_skill.rst +++ b/docs/user_guide/nvflare_cli/autofl_skill.rst @@ -15,6 +15,12 @@ Instead, NVFlare provides the deterministic import, reviewable and reproducibility evidence. The agent plans candidate edits and runs them through existing NVFlare surfaces. +``autofl.yaml`` is the human-reviewable campaign configuration, not a replacement +for ``job.py`` or for exported NVFlare job folders. It exposes the editable +Auto-FL settings, fixed-budget constraints, allowed edit paths, objective, +candidate budget, provenance, and unresolved fields. The original ``job.py`` +remains the experiment entry point the skill and agent use to run candidates. + Typical Prompt ============== @@ -38,8 +44,8 @@ The skill first imports the job without executing user code: --output autofl.yaml The importer parses supported Recipe and FedJob patterns with Python AST -inspection. It extracts known fields into ``autofl.yaml`` and marks unknown or -dynamic fields as unresolved instead of guessing. +inspection. It extracts campaign-relevant settings into ``autofl.yaml`` and +marks unknown or dynamic fields as unresolved instead of guessing. Trust Contract ============== @@ -47,16 +53,16 @@ Trust Contract Before editing or running candidates, the skill should show the user three things from ``autofl.yaml``: -- **Extracted**: recipe or FedJob surface, train script, metric, environment, - fixed budget, tunables, artifact locations, source hash, and importer version. +- **Editable**: metric, environment, candidate budget, tunables, artifact + locations, source hash, and importer version. - **Unresolved**: dynamic defaults, unsupported Python semantics, missing metric sources, unknown data paths, or low-confidence fields. - **Allowed**: files the agent may edit, fixed-budget fields it must preserve, and environment or policy boundaries. This makes the workflow feel native and reproducible: NVFlare owns the truth of -what was imported and executed; the agent owns exploration within explicit -constraints. +the campaign settings and execution surfaces; the agent owns exploration within +explicit constraints. Execution ========= From a29f84f3a75472478b72268d98eee33139cfca35 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Fri, 12 Jun 2026 13:35:52 -0400 Subject: [PATCH 03/23] Move Auto-FL skill to root skills layout --- MANIFEST.in | 2 +- docs/design/autofl_skill.md | 7 +- docs/user_guide/nvflare_cli/autofl_skill.rst | 4 + nvflare/agent/__init__.py | 15 --- nvflare/agent/skills/__init__.py | 15 --- nvflare/agent/skills/autofl/__init__.py | 15 --- nvflare/tool/install_skills.py | 111 +---------------- setup.py | 1 - skills/nvflare-autofl/BENCHMARK.md | 24 ++++ .../autofl => skills/nvflare-autofl}/SKILL.md | 53 +++++--- skills/nvflare-autofl/evals/evals.json | 113 ++++++++++++++++++ tests/unit_test/tool/install_skills_test.py | 52 -------- 12 files changed, 188 insertions(+), 224 deletions(-) delete mode 100644 nvflare/agent/__init__.py delete mode 100644 nvflare/agent/skills/__init__.py delete mode 100644 nvflare/agent/skills/autofl/__init__.py create mode 100644 skills/nvflare-autofl/BENCHMARK.md rename {nvflare/agent/skills/autofl => skills/nvflare-autofl}/SKILL.md (55%) create mode 100644 skills/nvflare-autofl/evals/evals.json delete mode 100644 tests/unit_test/tool/install_skills_test.py diff --git a/MANIFEST.in b/MANIFEST.in index c43f4f3244..79975e8966 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,6 @@ include versioneer.py include nvflare/_version.py include nvflare/libs/*.so include nvflare/fuel/utils/*.json -recursive-include nvflare/agent/skills *.md +recursive-include skills * recursive-include nvflare/lighter/templates * recursive-include nvflare/tool/deploy/templates * diff --git a/docs/design/autofl_skill.md b/docs/design/autofl_skill.md index f29dae802a..c0ef3f5bfb 100644 --- a/docs/design/autofl_skill.md +++ b/docs/design/autofl_skill.md @@ -17,7 +17,8 @@ Auto-FL an NVFlare-owned feature. The first production-oriented slice includes: -- A bundled `nvflare-autofl` agent skill. +- A root `skills/nvflare-autofl` agent skill that follows the NVFLARE skills + layout used by the general agent-skills work. - A deterministic `job.py` importer that emits reviewable `autofl.yaml` for the Auto-FL campaign. - A trust contract in `autofl.yaml` showing editable campaign settings, @@ -112,6 +113,6 @@ promotion command is needed. review and candidate comparability? - Which exported-job fields should be used as validation evidence versus static `job.py` parsing for authoring intent? -- Is the skill installer target behavior acceptable, or should NVFlare add a - dedicated generic agent-skill install command in a follow-up? +- Does the Auto-FL skill pass the general NVFLARE skill frontmatter, trigger, + and eval checks after it lands under `skills/nvflare-autofl`? - Which metric/artifact extraction gaps should become stable NVFlare APIs next? diff --git a/docs/user_guide/nvflare_cli/autofl_skill.rst b/docs/user_guide/nvflare_cli/autofl_skill.rst index f2ed25d6ba..2fd43439b1 100644 --- a/docs/user_guide/nvflare_cli/autofl_skill.rst +++ b/docs/user_guide/nvflare_cli/autofl_skill.rst @@ -9,6 +9,10 @@ existing NVFlare ``job.py``. The user entry point is the coding agent skill: select the NVFlare Auto-FL skill, point it at a job, and state the objective, environment, and candidate budget. +The skill source lives in ``skills/nvflare-autofl`` with the other NVFlare-owned +agent skills. When the general agent skill CLI is available, install it through +the standard ``nvflare agent skills`` workflow for the target coding agent. + NVFlare does not add a separate public Auto-FL command family for this workflow. Instead, NVFlare provides the deterministic import, reviewable ``autofl.yaml`` contract, execution substrate, policy boundaries, artifacts, diff --git a/nvflare/agent/__init__.py b/nvflare/agent/__init__.py deleted file mode 100644 index 38db1a3ea2..0000000000 --- a/nvflare/agent/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Packaged agent-facing NVFlare assets.""" diff --git a/nvflare/agent/skills/__init__.py b/nvflare/agent/skills/__init__.py deleted file mode 100644 index 7b87bb756c..0000000000 --- a/nvflare/agent/skills/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Bundled NVFlare skills for coding-agent workflows.""" diff --git a/nvflare/agent/skills/autofl/__init__.py b/nvflare/agent/skills/autofl/__init__.py deleted file mode 100644 index 2ec26a6582..0000000000 --- a/nvflare/agent/skills/autofl/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""NVFlare Auto-FL agent skill package.""" diff --git a/nvflare/tool/install_skills.py b/nvflare/tool/install_skills.py index 57c013cfe6..e82c7ccfa6 100644 --- a/nvflare/tool/install_skills.py +++ b/nvflare/tool/install_skills.py @@ -12,113 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os -import shutil -from datetime import datetime, timezone -from importlib import resources -from pathlib import Path -from typing import Iterable, Optional -_SKILL_PACKAGES = { - "nvflare-autofl": "nvflare.agent.skills.autofl", -} +def install_skills(): + """Minimal stub for skill installation. - -def available_skills(): - """Return bundled agent skills that can be installed.""" - - return [{"name": name, "package": package} for name, package in sorted(_SKILL_PACKAGES.items())] - - -def install_skills( - target_dir: Optional[str] = None, - skill_names: Optional[Iterable[str]] = None, - dry_run: bool = False, -): - """Install bundled NVFlare agent skills into ``target_dir``. - - The default call remains safe for existing ``poc prepare`` users: if no - target directory is supplied and ``NVFLARE_SKILLS_DIR`` is unset, the - installer records a skip and performs no writes. This function intentionally - returns a summary dict and does not raise, because callers treat skills as an - optional enhancement. - - Args: - target_dir: Destination directory for skill subdirectories. - skill_names: Optional subset of bundled skill names. - dry_run: If True, report planned actions without writing files. + Phase 3 will flesh out this function to install NVFlare CLI skills. + Always returns a summary dict and never raises. Returns: - Summary with ``installed``, ``skipped``, ``backed_up``, and ``errors``. + dict: Summary with 'installed', 'skipped', and 'backed_up' lists. """ - - selected_names = list(skill_names) if skill_names is not None else sorted(_SKILL_PACKAGES) - resolved_target = target_dir or os.environ.get("NVFLARE_SKILLS_DIR") - result = { - "target_dir": resolved_target, - "installed": [], - "skipped": [], - "backed_up": [], - "errors": [], - "dry_run": dry_run, - } - - if not resolved_target: - for name in selected_names: - result["skipped"].append({"name": name, "reason": "no target_dir or NVFLARE_SKILLS_DIR configured"}) - return result - - target = Path(resolved_target).expanduser() - for name in selected_names: - package = _SKILL_PACKAGES.get(name) - if not package: - result["errors"].append({"name": name, "reason": "unknown bundled skill"}) - continue - - destination = target / name - try: - source = resources.files(package) - if _destination_matches_source(source, destination): - result["skipped"].append({"name": name, "path": str(destination), "reason": "already current"}) - continue - - backup_path = None - if destination.exists(): - backup_path = _backup_path(target, name) - if not dry_run: - backup_path.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(destination), str(backup_path)) - result["backed_up"].append({"name": name, "path": str(backup_path)}) - - if not dry_run: - destination.mkdir(parents=True, exist_ok=True) - _copy_skill_resources(source, destination) - result["installed"].append({"name": name, "path": str(destination)}) - except Exception as e: - result["errors"].append({"name": name, "reason": str(e)}) - return result - - -def _backup_path(target: Path, skill_name: str) -> Path: - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f") - return target / ".bak" / timestamp / skill_name - - -def _destination_matches_source(source, destination: Path) -> bool: - source_skill = source / "SKILL.md" - destination_skill = destination / "SKILL.md" - if not destination_skill.exists() or not source_skill.is_file(): - return False - return destination_skill.read_bytes() == source_skill.read_bytes() - - -def _copy_skill_resources(source, destination: Path) -> None: - for child in source.iterdir(): - if child.name == "__pycache__" or child.name.endswith((".py", ".pyc")): - continue - target = destination / child.name - if child.is_dir(): - target.mkdir(parents=True, exist_ok=True) - _copy_skill_resources(child, target) - elif child.is_file(): - target.write_bytes(child.read_bytes()) + return {"installed": [], "skipped": [], "backed_up": []} diff --git a/setup.py b/setup.py index 9af216cf1f..ef2fea3d7a 100644 --- a/setup.py +++ b/setup.py @@ -104,7 +104,6 @@ def remove_dir(target_path): ), package_data={ "": ["*.yml", "*.yaml", "*.tpl", "*.html", "*.js", "poc.zip", "*.config", "*.conf"], - "nvflare.agent.skills.autofl": ["*.md"], "nvflare.dashboard.application": extra_files, "nvflare.tool.job": job_templates, "nvflare.tool.deploy": deploy_templates, diff --git a/skills/nvflare-autofl/BENCHMARK.md b/skills/nvflare-autofl/BENCHMARK.md new file mode 100644 index 0000000000..c5f22b52e2 --- /dev/null +++ b/skills/nvflare-autofl/BENCHMARK.md @@ -0,0 +1,24 @@ +# Benchmark Summary + +Status: draft/internal pending runtime evaluation. + +Skill version: 0.1.0 +FLARE version: 2.8.0 minimum + +## Initial Checks + +| Check | Status | Notes | +| --- | --- | --- | +| Positive trigger | Draft | `autofl-optimize-existing-job` defines the primary Auto-FL prompt. | +| Adjacent negative trigger | Draft | PyTorch conversion routes to `nvflare-convert-pytorch`. | +| Diagnosis negative trigger | Draft | Failed-job diagnosis routes to `nvflare-diagnose-job`. | +| Global negative trigger | Draft | Non-FLARE prompts route to no skill. | +| Mandatory behavior | Draft | Behavior IDs cover deterministic import, campaign summary, bounded edits, and existing FLARE execution. | +| Prohibited behavior | Draft | Behavior IDs prohibit bypassing policy, editing outside allowed paths, and treating `autofl.yaml` as an exported job. | +| Process evaluation | Draft | Metrics cover import-first behavior, contract preservation, score extraction, and unwanted production actions. | + +## Known Gaps + +- Runtime agent-performance scoring has not been run yet. +- Adjacent negatives should be expanded after more NVFLARE workflow skills land. +- Production execution behavior needs site-policy fixture coverage before the skill graduates from draft status. diff --git a/nvflare/agent/skills/autofl/SKILL.md b/skills/nvflare-autofl/SKILL.md similarity index 55% rename from nvflare/agent/skills/autofl/SKILL.md rename to skills/nvflare-autofl/SKILL.md index 3e18246e0c..29081b4e4a 100644 --- a/nvflare/agent/skills/autofl/SKILL.md +++ b/skills/nvflare-autofl/SKILL.md @@ -1,18 +1,33 @@ --- name: nvflare-autofl -description: Optimize an existing NVFlare job through an agent-assisted Auto-FL workflow. Use when a user asks to improve accuracy, AUC, loss, runtime, or robustness of an NVFlare job in simulation, POC, or production while preserving NVFlare execution, policy, artifacts, and reproducibility. +description: "Optimize an existing NVFLARE job.py through an agent-assisted Auto-FL campaign that preserves FLARE execution, policy, artifacts, and reproducibility." +min_flare_version: "2.8.0" +blast_radius: submits_production +skill_version: "0.1.0" --- -# NVFlare Auto-FL +# NVFLARE Auto-FL -Use this skill to optimize an existing NVFlare `job.py` without asking the user -to learn a new Auto-FL command tree. The user selects this skill, points to a -job, and states the objective, environment, and budget. NVFlare provides the -deterministic import, execution substrate, policy boundaries, artifacts, and -machine-readable contracts. The coding agent plans, edits allowed files, runs -candidates, compares results, and reports evidence. +## Use When + +Use this skill when the user asks to optimize an existing NVFLARE `job.py` for +accuracy, AUC, loss, runtime, robustness, or another metric in simulation, POC, +or production. + +## Do Not Use When + +Do not use for converting non-FL training code into NVFLARE, diagnosing failed +jobs without an optimization goal, production deployment setup, or generic +hyperparameter tuning outside an NVFLARE job. -## Required First Step +## Workflow + +Use this skill to optimize an existing NVFLARE `job.py` without asking the user +to learn a new Auto-FL command tree. The user selects this skill, points to a +job, and states the objective, environment, and budget. NVFLARE provides the +deterministic campaign import, execution substrate, policy boundaries, +artifacts, and machine-readable contracts. The coding agent plans, edits allowed +files, runs candidates, compares results, and reports evidence. Before editing files, import the job deterministically: @@ -20,25 +35,29 @@ Before editing files, import the job deterministically: python -m nvflare.app_common.autofl.job_importer ./job.py --metric --env --max-candidates --output autofl.yaml ``` -Read `autofl.yaml` and show the user a concise trust summary: +Read `autofl.yaml` and show the user a concise campaign summary: -- **Extracted**: recipe or FedJob surface, train script, metric, environment, - fixed budget, tunables, artifact locations, and provenance. +- **Editable**: metric, environment, candidate budget, tunables, artifact + locations, source hash, and importer version. - **Unresolved**: dynamic defaults, unsupported Python semantics, missing metric sources, unknown data paths, or any low-confidence fields. - **Allowed**: files the agent may edit, fixed-budget fields it must preserve, and policy boundaries for the requested environment. +Treat `autofl.yaml` as the human-reviewable Auto-FL campaign config, not as a +replacement for `job.py` or an exported NVFLARE job folder. Use the original +`job.py` as the runnable experiment entry point throughout the candidate loop. + If `autofl.yaml` contains unresolved fields that affect execution safety, candidate comparability, or production submission, ask the user to resolve those specific fields before running candidates. -## Operating Rules +## Requirements - Do not edit outside `job.allowed_edit_paths`. - Preserve `budget.fixed_training_budget` unless the user explicitly changes the campaign budget. -- Use NVFlare's existing execution surfaces: +- Use NVFLARE's existing execution surfaces: - For simulation, run the imported job with its configured `SimEnv`. - For POC and production, use standard `nvflare job submit`, `job wait`, `job download`, and related job/status commands. @@ -46,7 +65,7 @@ specific fields before running candidates. run command, metric result, artifacts, and failure reason when applicable. - Prefer small, reviewable edits over broad rewrites. - Treat production as an available execution environment, but never bypass - startup-kit authentication, site policy, or normal NVFlare job submission. + startup-kit authentication, site policy, or normal NVFLARE job submission. ## Candidate Loop @@ -54,8 +73,8 @@ specific fields before running candidates. 2. Propose a candidate change tied to one or more supported tunables or files. 3. Edit only allowed files. 4. Validate the job can still be imported and the fixed budget still matches. -5. Run the candidate through NVFlare in the requested environment. -6. Extract the requested metric from NVFlare artifacts/logs. +5. Run the candidate through NVFLARE in the requested environment. +6. Extract the requested metric from NVFLARE artifacts/logs. 7. Update the candidate ledger and keep the best reproducible candidate. 8. Stop when the budget is exhausted, the user stops the run, or results plateau. diff --git a/skills/nvflare-autofl/evals/evals.json b/skills/nvflare-autofl/evals/evals.json new file mode 100644 index 0000000000..45198bf6ab --- /dev/null +++ b/skills/nvflare-autofl/evals/evals.json @@ -0,0 +1,113 @@ +{ + "skill_name": "nvflare-autofl", + "evals": [ + { + "id": "autofl-optimize-existing-job", + "prompt": "Use NVFLARE Auto-FL to optimize ./job.py for validation accuracy in simulation with an 8-candidate budget.", + "expected_output": "The agent invokes the NVFLARE Auto-FL skill, imports job.py into autofl.yaml, summarizes editable/unresolved/allowed campaign settings, and then runs bounded candidates through existing FLARE execution surfaces.", + "files": [], + "assertions": [ + "The agent generates autofl.yaml before editing files.", + "The agent summarizes editable settings, unresolved fields, fixed-budget constraints, and allowed edit paths.", + "The agent runs candidates using the existing job.py rather than replacing it with autofl.yaml.", + "The agent records candidate results and reports the best reproducible candidate." + ], + "nvflare": { + "expected_skill": "nvflare-autofl", + "mandatory_behavior": [ + { + "id": "deterministic-import-first", + "description": "runs deterministic job import before candidate edits" + }, + { + "id": "campaign-summary-before-runs", + "description": "shows editable, unresolved, allowed, and fixed-budget campaign fields before running candidates" + }, + { + "id": "bounded-edit-surface", + "description": "edits only files allowed by autofl.yaml" + }, + { + "id": "existing-flare-execution", + "description": "uses job.py and standard FLARE execution surfaces for candidate runs" + } + ], + "prohibited_behavior": [ + { + "id": "no-autofl-yaml-as-exported-job", + "description": "does not treat autofl.yaml as a replacement for job.py or an exported FLARE job" + }, + { + "id": "no-policy-bypass", + "description": "does not bypass startup-kit authentication, site policy, or normal job submission for production runs" + }, + { + "id": "no-out-of-scope-edits", + "description": "does not edit outside the allowed edit paths" + } + ], + "process_metrics": [ + { + "id": "import_before_edit", + "description": "whether deterministic import occurs before candidate code edits" + }, + { + "id": "fixed_budget_violation_count", + "description": "number of candidate runs that changed fixed-budget fields without user approval" + }, + { + "id": "metric_extraction_success", + "description": "whether the requested metric is extracted from NVFLARE artifacts or logs" + }, + { + "id": "unwanted_production_action_count", + "description": "number of production submissions or policy-sensitive actions performed without explicit user context" + }, + { + "id": "report_reproducibility", + "description": "whether the final report includes commands, artifacts, changed files, and the best candidate" + } + ] + } + }, + { + "id": "autofl-negative-pytorch-conversion", + "prompt": "Convert this standalone PyTorch training script into an NVFLARE federated job.", + "expected_output": "The Auto-FL skill should not be the lead; a conversion skill should handle the request before Auto-FL is applicable.", + "files": [], + "assertions": [ + "The selected skill is nvflare-convert-pytorch, not nvflare-autofl." + ], + "nvflare": { + "expected_skill": "nvflare-convert-pytorch", + "negative_for": "nvflare-autofl" + } + }, + { + "id": "autofl-negative-diagnose-job", + "prompt": "My NVFLARE job failed with EXECUTION_EXCEPTION. Diagnose the client logs and tell me what went wrong.", + "expected_output": "The Auto-FL skill should not be the lead; failed-job diagnosis should route to nvflare-diagnose-job.", + "files": [], + "assertions": [ + "The selected skill is nvflare-diagnose-job, not nvflare-autofl." + ], + "nvflare": { + "expected_skill": "nvflare-diagnose-job", + "negative_for": "nvflare-autofl" + } + }, + { + "id": "autofl-global-negative-web-app", + "prompt": "Build a React landing page for a bakery.", + "expected_output": "No FLARE skill should trigger.", + "files": [], + "assertions": [ + "The selected skill is none." + ], + "nvflare": { + "expected_skill": null, + "negative_for": "*" + } + } + ] +} diff --git a/tests/unit_test/tool/install_skills_test.py b/tests/unit_test/tool/install_skills_test.py deleted file mode 100644 index aad1e31a98..0000000000 --- a/tests/unit_test/tool/install_skills_test.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from nvflare.tool.install_skills import available_skills, install_skills - - -def test_available_skills_includes_autofl(): - assert {"name": "nvflare-autofl", "package": "nvflare.agent.skills.autofl"} in available_skills() - - -def test_install_skills_without_target_is_safe(monkeypatch): - monkeypatch.delenv("NVFLARE_SKILLS_DIR", raising=False) - - result = install_skills() - - assert result["installed"] == [] - assert result["errors"] == [] - assert result["skipped"] == [{"name": "nvflare-autofl", "reason": "no target_dir or NVFLARE_SKILLS_DIR configured"}] - - -def test_install_skills_copies_bundled_autofl_skill(tmp_path): - result = install_skills(target_dir=str(tmp_path)) - - skill_path = tmp_path / "nvflare-autofl" / "SKILL.md" - assert result["errors"] == [] - assert result["installed"] == [{"name": "nvflare-autofl", "path": str(tmp_path / "nvflare-autofl")}] - assert skill_path.exists() - assert "NVFlare Auto-FL" in skill_path.read_text(encoding="utf-8") - - second = install_skills(target_dir=str(tmp_path)) - assert second["installed"] == [] - assert second["skipped"] == [ - {"name": "nvflare-autofl", "path": str(tmp_path / "nvflare-autofl"), "reason": "already current"} - ] - - -def test_install_skills_dry_run_does_not_write(tmp_path): - result = install_skills(target_dir=str(tmp_path), dry_run=True) - - assert result["installed"] == [{"name": "nvflare-autofl", "path": str(tmp_path / "nvflare-autofl")}] - assert not (tmp_path / "nvflare-autofl").exists() From 989bf4570f15f574ad2e3dab493e08511ec55251 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Fri, 12 Jun 2026 13:51:37 -0400 Subject: [PATCH 04/23] Address Auto-FL importer review feedback --- nvflare/app_common/autofl/job_importer.py | 31 ++++++---- .../app_common/autofl/job_importer_test.py | 57 +++++++++++++++++++ 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/nvflare/app_common/autofl/job_importer.py b/nvflare/app_common/autofl/job_importer.py index d15e060dde..10e8ddd482 100644 --- a/nvflare/app_common/autofl/job_importer.py +++ b/nvflare/app_common/autofl/job_importer.py @@ -25,6 +25,7 @@ import argparse import ast import hashlib +import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Tuple @@ -217,7 +218,7 @@ def import_job( }, "trust_contract": { "extracted": _trust_extracted(job_call, env_call, train_script, budget, metric_name, search_space), - "unresolved": unresolved, + "unresolved": list(unresolved), "allowed_edit_paths": allowed_edit_paths, "agent_controls": { "must_not_edit_outside_allowed_paths": True, @@ -225,7 +226,7 @@ def import_job( "must_report_candidate_diffs": True, }, }, - "unresolved": unresolved, + "unresolved": list(unresolved), } return config @@ -268,9 +269,9 @@ def _resolve_train_script( resolved = _resolve_value(train_script_node, job_call.assignments, parser_args, source_text) value = resolved.value - if isinstance(value, str): + if isinstance(value, str) and _is_resolved_path_string(resolved): return _existing_path((source_path.parent / value).resolve()) - return _existing_path(source_path.parent / "client.py") + return None def _resolve_metric( self, @@ -761,6 +762,10 @@ def _existing_path(path: Path) -> Optional[Path]: return path.resolve() if path.exists() else None +def _is_resolved_path_string(value: ResolvedValue) -> bool: + return not value.unresolved and (value.source == "literal" or value.source.startswith("arg:")) + + def _sha256_text(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() @@ -800,13 +805,17 @@ def main(argv: Optional[List[str]] = None) -> int: args = parser.parse_args(argv) importer = DeterministicJobImporter() - config = importer.import_job( - args.job, - metric=args.metric, - mode=args.mode, - target_env=args.target_env, - max_candidates=args.max_candidates, - ) + try: + config = importer.import_job( + args.job, + metric=args.metric, + mode=args.mode, + target_env=args.target_env, + max_candidates=args.max_candidates, + ) + except JobImportError as e: + print(f"error: {e}", file=sys.stderr) + return 1 output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(importer.dump_yaml(config), encoding="utf-8") diff --git a/tests/unit_test/app_common/autofl/job_importer_test.py b/tests/unit_test/app_common/autofl/job_importer_test.py index 50e6f6c092..1d3cbd8c6d 100644 --- a/tests/unit_test/app_common/autofl/job_importer_test.py +++ b/tests/unit_test/app_common/autofl/job_importer_test.py @@ -19,6 +19,7 @@ DeterministicJobImporter, dump_autofl_yaml, import_job_to_autofl_config, + job_importer, ) @@ -128,6 +129,7 @@ def test_import_is_repeatable_and_yaml_round_trips(tmp_path): assert first == second assert yaml.safe_load(yaml_text) == first assert "&id" not in yaml_text + assert first["trust_contract"]["unresolved"] is not first["unresolved"] def test_import_marks_dynamic_argparse_defaults_unresolved(tmp_path): @@ -186,6 +188,49 @@ def main(): } in config["unresolved"] +def test_import_marks_dynamic_train_script_unresolved_without_client_fallback(tmp_path): + (tmp_path / "client.py").write_text( + """ +import argparse + + +def build_parser(): + parser = argparse.ArgumentParser() + parser.add_argument("--lr", type=float, default=0.01) + return parser +""", + encoding="utf-8", + ) + job_path = tmp_path / "job.py" + job_path.write_text( + """ +from nvflare.app_opt.pt.recipes.fedavg import FedAvgRecipe +from nvflare.recipe import SimEnv + + +def get_script(): + return "client.py" + + +def main(): + recipe = FedAvgRecipe( + name="demo", + min_clients=2, + num_rounds=3, + train_script=get_script(), + ) + recipe.execute(SimEnv(num_clients=2)) +""", + encoding="utf-8", + ) + + config = import_job_to_autofl_config(str(job_path), workspace_root=str(tmp_path)) + + assert "train_script" not in config["job"] + assert "client.py" not in config["trust_contract"]["allowed_edit_paths"] + assert {"field": "job.train_script", "reason": "no train_script was found or resolved"} in config["unresolved"] + + def test_import_marks_unsupported_custom_job_as_partial(tmp_path): job_path = tmp_path / "job.py" job_path.write_text( @@ -208,3 +253,15 @@ def main(): assert "job.surface" in unresolved_fields assert "job.train_script" in unresolved_fields assert "budget.fixed_training_budget" in unresolved_fields + + +def test_main_returns_clean_error_for_missing_job(tmp_path, capsys): + output_path = tmp_path / "autofl.yaml" + + exit_code = job_importer.main([str(tmp_path / "missing.py"), "--output", str(output_path)]) + + captured = capsys.readouterr() + assert exit_code == 1 + assert captured.out == "" + assert "error: job.py not found:" in captured.err + assert not output_path.exists() From 1d64aa437065321381c75b5dd5280218b2af4839 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Fri, 12 Jun 2026 15:10:11 -0400 Subject: [PATCH 05/23] Mark unresolved Auto-FL importer names --- nvflare/app_common/autofl/job_importer.py | 2 +- .../app_common/autofl/job_importer_test.py | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/nvflare/app_common/autofl/job_importer.py b/nvflare/app_common/autofl/job_importer.py index 10e8ddd482..0ea15cfef6 100644 --- a/nvflare/app_common/autofl/job_importer.py +++ b/nvflare/app_common/autofl/job_importer.py @@ -621,7 +621,7 @@ def _resolve_value( return _resolve_arg_default(node.id, parser_args[node.id]) if node.id in assignments: return _resolve_value(assignments[node.id], assignments, parser_args, source_text, seen | {node.id}) - return ResolvedValue(node.id, f"name:{node.id}", "medium") + return ResolvedValue(node.id, f"name:{node.id}", "low", True) if isinstance(node, ast.Call): call_name = _call_name(node.func) diff --git a/tests/unit_test/app_common/autofl/job_importer_test.py b/tests/unit_test/app_common/autofl/job_importer_test.py index 1d3cbd8c6d..7033db9914 100644 --- a/tests/unit_test/app_common/autofl/job_importer_test.py +++ b/tests/unit_test/app_common/autofl/job_importer_test.py @@ -231,6 +231,48 @@ def main(): assert {"field": "job.train_script", "reason": "no train_script was found or resolved"} in config["unresolved"] +def test_import_marks_imported_budget_and_metric_constants_unresolved(tmp_path): + (tmp_path / "client.py").write_text( + """ +def train(): + pass +""", + encoding="utf-8", + ) + job_path = tmp_path / "job.py" + job_path.write_text( + """ +from config import KEY_METRIC, NUM_ROUNDS +from nvflare.app_opt.pt.recipes.fedavg import FedAvgRecipe +from nvflare.recipe import SimEnv + + +def main(): + recipe = FedAvgRecipe( + name="demo", + min_clients=2, + num_rounds=NUM_ROUNDS, + train_script="client.py", + key_metric=KEY_METRIC, + ) + recipe.execute(SimEnv(num_clients=2)) +""", + encoding="utf-8", + ) + + config = import_job_to_autofl_config(str(job_path), workspace_root=str(tmp_path)) + + assert config["objective"] == {"metric": "accuracy", "mode": "max", "source": "default"} + assert config["budget"]["fixed_training_budget"] == {"min_clients": 2, "num_clients": 2} + assert { + "field": "budget.fixed_training_budget.num_rounds", + "reason": "name:NUM_ROUNDS", + } in config["unresolved"] + assert {"field": "objective.metric", "reason": "name:KEY_METRIC"} in config["unresolved"] + assert {"field": "job.FedAvgRecipe.key_metric", "reason": "name:KEY_METRIC"} in config["unresolved"] + assert {"field": "job.FedAvgRecipe.num_rounds", "reason": "name:NUM_ROUNDS"} in config["unresolved"] + + def test_import_marks_unsupported_custom_job_as_partial(tmp_path): job_path = tmp_path / "job.py" job_path.write_text( From ead5ff2082108783337d53945e31cb187ae05b5a Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Thu, 25 Jun 2026 09:52:05 -0400 Subject: [PATCH 06/23] Keep Auto-FL skill campaigns running --- research/auto-fl-research/.gitignore | 2 + research/auto-fl-research/program.md | 31 +- .../scripts/campaign_guard.py | 279 ++++ .../skills/autofl-nvflare/SKILL.md | 16 +- .../autofl-nvflare/references/runbook.md | 10 +- .../auto-fl-research/tasks/cifar10/profile.md | 12 + skills/nvflare-autofl/README.md | 97 ++ skills/nvflare-autofl/SKILL.md | 240 ++- .../scripts/launch_h100_skill_trial.sh | 212 +++ .../scripts/run_job_campaign.py | 1328 +++++++++++++++++ .../research/autofl_campaign_guard_test.py | 95 ++ .../tool/autofl_skill_runner_test.py | 195 +++ 12 files changed, 2490 insertions(+), 27 deletions(-) create mode 100755 research/auto-fl-research/scripts/campaign_guard.py create mode 100644 skills/nvflare-autofl/README.md create mode 100755 skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh create mode 100644 skills/nvflare-autofl/scripts/run_job_campaign.py create mode 100644 tests/unit_test/research/autofl_campaign_guard_test.py create mode 100644 tests/unit_test/tool/autofl_skill_runner_test.py diff --git a/research/auto-fl-research/.gitignore b/research/auto-fl-research/.gitignore index 07ad9505c0..1161b039bc 100644 --- a/research/auto-fl-research/.gitignore +++ b/research/auto-fl-research/.gitignore @@ -13,3 +13,5 @@ run.log .DS_Store .venv progress.png +.autoresearch/ +campaign_state.json diff --git a/research/auto-fl-research/program.md b/research/auto-fl-research/program.md index 47c6a0f7ed..b57d7e859b 100644 --- a/research/auto-fl-research/program.md +++ b/research/auto-fl-research/program.md @@ -250,10 +250,24 @@ launching more candidates. If it prints `recommendation=continue`, keep iterating locally unless repeated crashes share one root cause or no non-duplicate safe axis remains. +11. Run the campaign guard and obey its machine-readable stop/continue state: + + ```bash + "${PYTHON}" scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json --format json + ``` + +The guard owns whether a final response is allowed. If it writes +`"final_response_allowed": false`, do not produce a final answer: execute the +reported `next_action` immediately. Reports, plots, commits, watchdog checks, +and encoded `job.py` defaults are checkpoints, not stop conditions, unless the +guard reports a stop state. + ## Never Stop Once setup and the initial baseline are complete, continue autonomously until -manually interrupted. Do not pause to ask whether to keep going. +manually interrupted. Do not pause to ask whether to keep going. The +deterministic stop authority is `scripts/campaign_guard.py`; run it after every +batch/checkpoint and before any final response. Cycle through: @@ -265,13 +279,15 @@ Cycle through: - finalize `results.tsv` statuses - run the plateau watchdog - switch to literature mode when triggered +- run `scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json` - commit surviving code and ledger checkpoints - repeat If local ideas run out, inspect recent near-misses in `results.tsv`, reread the task profile and its `mutation_schema.yaml`, combine compatible kept settings, or switch to the literature loop. Stay within the hard invariants and active -budget. +budget. If the guard says `decision=continue`, choose a next axis even when the +current best stack has been encoded into defaults and verified. ## Literature Loop @@ -389,3 +405,14 @@ At checkpoints, generate a compact progress plot with: ```bash "${PYTHON:-python3}" scripts/plot_progress.py results.tsv --output progress.png ``` + +Then refresh the campaign state: + +```bash +"${PYTHON:-python3}" scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json +``` + +Only write a final run report after the guard prints +`final_response_allowed=true`, for example because the human created a stop +file, an explicit candidate cap was exhausted, or a hard repeated-failure +blocker prevents comparable runs. diff --git a/research/auto-fl-research/scripts/campaign_guard.py b/research/auto-fl-research/scripts/campaign_guard.py new file mode 100755 index 0000000000..896fb461fd --- /dev/null +++ b/research/auto-fl-research/scripts/campaign_guard.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Write the deterministic Auto-FL campaign continuation state. + +This script owns the stop/continue decision for the research harness. Agents may +choose mutations, but they must not decide that a campaign is complete while this +guard says another comparable batch should run. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DEFAULT_MAX_SCORED_SINCE_RESET = 32 +DEFAULT_MIN_DELTA = 0.0005 +DEFAULT_STATE_PATH = ".autoresearch/campaign_state.json" +DEFAULT_STOP_FILES = ("STOP_AUTOFL", ".autoresearch/STOP_AUTOFL", ".nvflare/autofl/STOP") +COMPARABLE_STATUSES = {"candidate", "keep", "discard", "crash"} + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def parse_score(value: str) -> float | None: + try: + score = float(value) + except (TypeError, ValueError): + return None + if math.isnan(score) or math.isinf(score): + return None + return score + + +def load_rows(path: Path) -> list[dict[str, str]]: + if not path.exists(): + return [] + with path.open("r", encoding="utf-8", newline="") as f: + return list(csv.DictReader(f, delimiter="\t")) + + +def normalize_status(row: dict[str, str]) -> str: + return (row.get("status", "") or "").strip().lower() + + +def is_baseline(row: dict[str, str]) -> bool: + text = " ".join( + [ + row.get("description", "") or "", + row.get("budget", "") or "", + row.get("artifacts", "") or "", + ] + ).lower() + return "baseline" in text or "--name baseline" in text + + +def comparable_attempts(rows: list[dict[str, str]]) -> list[dict[str, str]]: + return [row for row in rows if normalize_status(row) in COMPARABLE_STATUSES and not is_baseline(row)] + + +def scored_attempts(rows: list[dict[str, str]]) -> list[dict[str, str]]: + return [row for row in comparable_attempts(rows) if parse_score(row.get("score", "")) is not None] + + +def pending_candidates(rows: list[dict[str, str]]) -> list[dict[str, str]]: + return [row for row in rows if normalize_status(row) == "candidate"] + + +def best_score(rows: list[dict[str, str]]) -> float | None: + scores = [parse_score(row.get("score", "")) for row in rows] + scores = [score for score in scores if score is not None] + if not scores: + return None + return max(scores) + + +def parse_max_candidates(value: str | None) -> int | None: + if value is None or str(value).strip() == "": + return None + parsed = int(value) + if parsed <= 0: + return None + return parsed + + +def existing_stop_files(paths: list[str]) -> list[str]: + return [path for path in paths if Path(path).exists()] + + +def repeated_crash_blocker(attempts: list[dict[str, str]], threshold: int) -> bool: + if threshold <= 0 or len(attempts) < threshold: + return False + return all(normalize_status(row) == "crash" for row in attempts[-threshold:]) + + +def parse_key_value_output(text: str) -> dict[str, str]: + values = {} + for line in text.splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def run_watchdog(results_path: Path, threshold: int, min_delta: float) -> dict[str, Any]: + watchdog = Path(__file__).resolve().with_name("plateau_watchdog.py") + if not watchdog.exists() or not results_path.exists(): + return {"available": False, "recommendation": "continue", "raw": ""} + + command = [ + sys.executable, + str(watchdog), + str(results_path), + "--max-scored-since-reset", + str(threshold), + "--min-delta", + str(min_delta), + ] + process = subprocess.run(command, text=True, capture_output=True, check=False) + raw = process.stdout.strip() + parsed = parse_key_value_output(raw) + recommendation = parsed.get("recommendation") or "continue" + return { + "available": True, + "returncode": process.returncode, + "recommendation": recommendation, + "fields": parsed, + "raw": raw, + "error": process.stderr.strip(), + } + + +def guard_state(args) -> dict[str, Any]: + results_path = Path(args.results) + rows = load_rows(results_path) + attempts = comparable_attempts(rows) + pending = pending_candidates(rows) + cap = args.max_candidates + if cap is None: + cap = parse_max_candidates(os.environ.get("AUTOFL_MAX_CANDIDATES")) + stop_files = existing_stop_files(args.stop_file) + watchdog = run_watchdog(results_path, args.plateau_threshold, args.min_delta) + + decision = "continue" + reason = "continue" + next_action = "launch_next_candidate_batch" + final_response_allowed = False + + if pending: + reason = "pending_candidates" + next_action = "finalize_pending_candidates" + elif stop_files: + decision = "stop" + reason = "manual_stop_file" + next_action = "final_report" + final_response_allowed = True + elif cap is not None and len(attempts) >= cap: + decision = "stop" + reason = "candidate_cap_exhausted" + next_action = "final_report" + final_response_allowed = True + elif repeated_crash_blocker(attempts, args.hard_crash_threshold): + decision = "stop" + reason = "hard_repeated_crash_blocker" + next_action = "final_report" + final_response_allowed = True + elif watchdog.get("recommendation") == "literature": + reason = "plateau_literature" + next_action = "run_literature_loop" + + if final_response_allowed: + instruction = "Final report is allowed because the campaign guard reached a stop condition." + elif next_action == "finalize_pending_candidates": + instruction = ( + "Do not produce a final answer. Finalize reviewed candidate rows, refresh artifacts, then rerun the guard." + ) + elif next_action == "run_literature_loop": + instruction = "Do not produce a final answer. Run the literature loop, log the event, and launch source-backed candidates." + else: + instruction = "Do not produce a final answer. Launch the next same-budget candidate batch now." + + return { + "schema_version": "nvflare.autofl.campaign_state.v1", + "updated_at": utc_now(), + "results": str(results_path), + "decision": decision, + "reason": reason, + "next_action": next_action, + "final_response_allowed": final_response_allowed, + "candidate_cap": cap, + "candidate_attempts": len(attempts), + "pending_candidates": len(pending), + "scored_attempts": len(scored_attempts(rows)), + "best_score": best_score(rows), + "stop_files": stop_files, + "watchdog": watchdog, + "agent_instruction": instruction, + } + + +def write_state(path: Path, state: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp_path.replace(path) + + +def print_text(state: dict[str, Any]) -> None: + for key in [ + "decision", + "reason", + "next_action", + "final_response_allowed", + "candidate_cap", + "candidate_attempts", + "pending_candidates", + "scored_attempts", + "best_score", + "agent_instruction", + ]: + value = state.get(key) + if isinstance(value, bool): + value = str(value).lower() + elif value is None: + value = "" + print(f"{key}={value}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("results", nargs="?", default="results.tsv") + parser.add_argument("--state", default=DEFAULT_STATE_PATH) + parser.add_argument("--max-candidates", type=parse_max_candidates) + parser.add_argument("--stop-file", action="append", default=list(DEFAULT_STOP_FILES)) + parser.add_argument("--plateau-threshold", type=int, default=DEFAULT_MAX_SCORED_SINCE_RESET) + parser.add_argument("--min-delta", type=float, default=DEFAULT_MIN_DELTA) + parser.add_argument("--hard-crash-threshold", type=int, default=6) + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args() + + if args.plateau_threshold <= 0: + raise ValueError("--plateau-threshold must be positive") + if args.min_delta < 0: + raise ValueError("--min-delta must be non-negative") + + state = guard_state(args) + write_state(Path(args.state), state) + if args.format == "json": + print(json.dumps(state, sort_keys=True)) + else: + print_text(state) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/research/auto-fl-research/skills/autofl-nvflare/SKILL.md b/research/auto-fl-research/skills/autofl-nvflare/SKILL.md index bc17884190..97c4540399 100644 --- a/research/auto-fl-research/skills/autofl-nvflare/SKILL.md +++ b/research/auto-fl-research/skills/autofl-nvflare/SKILL.md @@ -1,6 +1,6 @@ --- name: autofl-nvflare -description: Help coding agents work on an NVFlare-based Auto-FL harness that follows an autoresearch-style loop. Use when the user wants to create, edit, debug, or extend program.md, task folders such as tasks/cifar10/ and tasks/vlm_med/, task-local job.py, client.py, model.py, shared custom_aggregators.py, mutation policies, results.tsv logging, or coding-agent prompts for a bounded federated-learning research loop. This skill is specifically for NVFlare harness work where the Client API loop, DIFF upload contract, and NUM_STEPS_CURRENT_ROUND metadata must stay intact unless the user explicitly asks for a protocol upgrade. +description: Help coding agents work on an NVFlare-based Auto-FL harness that follows an autoresearch-style loop. Use when the user wants to create, edit, debug, or extend program.md, task folders such as tasks/cifar10/ and tasks/vlm_med/, task-local job.py, client.py, model.py, shared custom_aggregators.py, mutation policies, results.tsv logging, or coding-agent prompts for a guardrailed federated-learning research loop. This skill is specifically for NVFlare harness work where the Client API loop, DIFF upload contract, and NUM_STEPS_CURRENT_ROUND metadata must stay intact unless the user explicitly asks for a protocol upgrade. --- # autofl-nvflare @@ -76,9 +76,17 @@ After making edits: 12. commit that ledger on the active `autoresearch/` branch after baseline and completed runs/checkpoints, and commit surviving code changes as soon as they are kept rather than carrying them uncommitted into the next batch 13. if a candidate implements a paper-derived method, include a compact source ref in the `results.tsv` description field and fuller citation details in `templates/mutation_report.md` 14. rank the completed batch against the ledger before deciding whether to keep, narrow, or revert; rank primarily by score, use runtime as a coarse secondary signal, and prefer the faster/simpler candidate when scores are within noise -15. after setup and baseline, continue launching same-budget candidate batches until manually interrupted; do not ask whether to keep going -16. after every finalized batch, run `scripts/plateau_watchdog.py results.tsv`; if it prints `recommendation=literature`, stop local jitter sweeps and run the Camyla-inspired literature loop from `program.md`: time it with `scripts/log_literature_review.py --start` / `--finish`, generate diverse queries, triage primary papers, extract challenge cards, score contract-safe proposals in `templates/literature_loop.md`, record the `literature` event row in `results.tsv`, and launch the top compatible candidate batch next; if it prints `recommendation=continue`, do not log another literature row for a routine missed batch, and keep iterating locally unless repeated crashes share one root cause or no non-duplicate safe axis remains -17. report the mutation hypothesis, changed files, commands run, observed outcome, literature basis, run analysis, and next mutation +15. if the user gives an explicit `N`-candidate budget, count up to `N` comparable candidate attempts after the baseline. Do not count deterministic import, validation, smoke, plotting, reporting, the baseline, or infrastructure-only retries caused by sandbox/socket/runtime setup. Count a real candidate crash once the candidate run starts under the intended execution environment +16. if the user does not give an explicit candidate cap, run the original autoresearch loop: after setup and baseline, continue launching same-budget candidate batches until manually interrupted; do not invent a default stopping point, do not ask whether to keep going, and do not send a final response while safe comparable candidates remain. Progress updates in uncapped mode must be status observations, not "should I continue?" questions; continue unless the human explicitly interrupts or the campaign guard allows finalization +17. after every finalized batch, run `scripts/plateau_watchdog.py results.tsv`; treat plateau as a decision checkpoint, not an automatic stop. If it prints `recommendation=literature`, run the Camyla-inspired literature loop from `program.md`, record the `literature` event row in `results.tsv`, refresh `progress.png`, and launch the top compatible candidate batch next unless the user asked to stop. If it prints `recommendation=continue`, refresh `progress.png` and keep iterating locally unless repeated crashes share one root cause or no non-duplicate safe axis remains +18. after every finalized batch, report checkpoint, refreshed plot, local commit, encoded default verification, cap check, or possible stop point, run `"${PYTHON:-python3}" scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json --format json` +19. treat `.autoresearch/campaign_state.json` as authoritative campaign state. If `final_response_allowed` is `false`, do not produce a final answer; execute the returned `next_action` immediately. If `next_action` is `finalize_pending_candidates`, finalize the reviewed rows and rerun the guard. If it is `run_literature_loop`, run the literature loop and launch source-backed candidates. If it is `launch_next_candidate_batch`, choose the next same-budget axis and launch it +20. never treat a kept improvement, refreshed report, refreshed `progress.png`, encoded `job.py` defaults, or local commit as a stopping condition in an uncapped campaign; these are checkpoints before the next same-budget batch unless `campaign_guard.py` says `final_response_allowed=true` +21. report the mutation hypothesis, changed files, commands run, observed outcome, literature basis, run analysis, and next mutation; then launch the next candidate batch when the guard says to continue +22. refresh the progress plot after every finalized batch, cap exhaustion, manual stop, plateau checkpoint, or hard-blocker checkpoint with `"${PYTHON:-python3}" scripts/plot_progress.py results.tsv --output progress.png`; if matplotlib cache paths fail, retry with writable `MPLCONFIGDIR` and `XDG_CACHE_HOME` +23. if an active NVFLARE simulator candidate stalls on a hard child-process connection failure such as `Failed to create connection to the child process in SimulatorClientRunner`, or on a configured simulator no-progress watchdog after a round is dispatched but server/client progress markers stop advancing, recover inside the same campaign: mark only that candidate as `crash`, terminate the stuck `job.py` child if needed, refresh `results.tsv`, `progress.png`, and `.autoresearch/campaign_state.json`, then continue with the next same-budget candidate. Do not start a new campaign directory, new branch, new baseline, new objective, or final report unless the human explicitly asked to reset +24. do not treat a quiet NVFLARE server log as a stall by itself. After a round is dispatched, inspect `/tmp/nvflare/simulation//site-*/log.txt` or `site-*/log_fl.txt` for client epoch, finished-training, download, or task-completion progress before interrupting. If any client log or server aggregation marker advances within the expected candidate runtime, keep waiting on the same candidate; never stop the runner, final-answer, or start a new campaign for that pattern +25. only produce a final answer when `scripts/campaign_guard.py` reports `final_response_allowed=true`; then end with reproducible artifacts: finalized `results.tsv`, refreshed `progress.png`, `.autoresearch/campaign_state.json`, and a concise report or `templates/mutation_report.md` entry covering the baseline, best score, artifacts, command provenance, failures, product friction, and next mutation. The final answer must include absolute paths to `autofl.yaml` when present, `results.tsv`, `progress.png`, `.autoresearch/campaign_state.json`, and any report artifact, plus the best candidate, metric improvement, and whether the guard stopped because of a cap, manual interruption, or hard blocker ## References diff --git a/research/auto-fl-research/skills/autofl-nvflare/references/runbook.md b/research/auto-fl-research/skills/autofl-nvflare/references/runbook.md index d7038f9406..96a3f18664 100644 --- a/research/auto-fl-research/skills/autofl-nvflare/references/runbook.md +++ b/research/auto-fl-research/skills/autofl-nvflare/references/runbook.md @@ -19,10 +19,12 @@ 16. Finalize reviewed statuses before starting the next batch: promote the selected survivor to `keep`, mark reviewed non-survivors as `discard`, leave crashes as `crash`, and leave only unresolved active rows as `candidate`. Prefer `"${PYTHON}" scripts/finalize_batch_status.py results.tsv --last "${PARALLEL_CANDIDATES:-4}" --keep-best --discard-others`. 17. If a candidate implements a paper-derived method, include a compact source ref in the `results.tsv` description field and fuller citation details in `templates/mutation_report.md`. 18. Run `"${PYTHON}" scripts/plateau_watchdog.py results.tsv` after every finalized batch. If it prints `recommendation=literature`, stop local jitter sweeps and run a literature-grounded proposal loop before launching more candidates. If it prints `recommendation=continue`, keep iterating locally unless repeated crashes share one root cause or no non-duplicate safe axis remains. -19. Commit `results.tsv` on the active `autoresearch/` branch after baseline and each completed run/checkpoint. Commit surviving code changes as soon as they are kept; do not carry kept changes uncommitted into the next batch. -20. Continue with the next same-budget candidate batch until manually interrupted; do not ask whether to keep going after setup and baseline. -21. When the watchdog fires, or when repeated crashes share one root cause and a source-backed fix is needed before more runs are useful, run the literature loop: start timing with `"${PYTHON}" scripts/log_literature_review.py --start --description "plateau after : "`, search papers, extract challenges, score contract-safe ideas, append the `literature` event with `--finish`, and launch the top compatible candidate batch next. -22. Summarize the result when interrupted or when reporting a checkpoint. +19. Refresh deterministic campaign state with `"${PYTHON}" scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json --format json` after every finalized batch, report update, plot refresh, encoded default verification, or possible stop point. +20. Treat `.autoresearch/campaign_state.json` as authoritative. If `final_response_allowed=false`, do not summarize as final; execute `next_action` immediately. If `next_action=finalize_pending_candidates`, finalize reviewed rows and rerun the guard. If `next_action=run_literature_loop`, run the literature loop and launch source-backed candidates. If `next_action=launch_next_candidate_batch`, choose and launch the next same-budget batch. +21. Commit `results.tsv` on the active `autoresearch/` branch after baseline and each completed run/checkpoint. Commit surviving code changes as soon as they are kept; do not carry kept changes uncommitted into the next batch. +22. Continue with the next same-budget candidate batch until `campaign_guard.py` reports `final_response_allowed=true`; do not ask whether to keep going after setup and baseline. +23. When the watchdog fires, or when repeated crashes share one root cause and a source-backed fix is needed before more runs are useful, run the literature loop: start timing with `"${PYTHON}" scripts/log_literature_review.py --start --description "plateau after : "`, search papers, extract challenges, score contract-safe ideas, append the `literature` event with `--finish`, and launch the top compatible candidate batch next. +24. Summarize the result only when the guard reports `final_response_allowed=true`. ## Single-H100 mode For the default CIFAR-10/H100 profile, run same-budget candidate batches via `PYTHON=.venv/bin/python TASK_DIR=tasks/cifar10 bash scripts/run_iteration.sh`, with unique `RUN_LOG` and `--name` values for each concurrent candidate. Default to `PARALLEL_CANDIDATES=4`, and reduce the width if CUDA memory or host contention appears. For other profiles, use that profile's hardware, environment, and candidate-width rules. diff --git a/research/auto-fl-research/tasks/cifar10/profile.md b/research/auto-fl-research/tasks/cifar10/profile.md index caa30ee635..7558e124c9 100644 --- a/research/auto-fl-research/tasks/cifar10/profile.md +++ b/research/auto-fl-research/tasks/cifar10/profile.md @@ -177,6 +177,18 @@ After finalizing reviewed statuses, run the plateau watchdog before selecting an If it prints `recommendation=literature`, stop local hyperparameter jittering, run the literature loop, record a `literature` row, and launch the selected source-backed candidates next. If it prints `recommendation=continue`, do not start a literature review just because one or two small batches missed; choose a clearer local sweep axis, narrow around near-misses, or inspect `mutation_schema.yaml` for another allowed axis. +Then refresh deterministic campaign state: + +```bash +"${PYTHON}" scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json --format json +``` + +If the guard returns `final_response_allowed=false`, do not produce a final +answer. Execute its `next_action`: finalize pending rows, run the literature +loop, or launch the next same-budget batch. Encoded defaults and refreshed +reports are checkpoints, not stop conditions, unless the guard allows a final +response. + When finalizing a same-H100 batch, use the CIFAR default width: ```bash diff --git a/skills/nvflare-autofl/README.md b/skills/nvflare-autofl/README.md new file mode 100644 index 0000000000..9e2a8bfbef --- /dev/null +++ b/skills/nvflare-autofl/README.md @@ -0,0 +1,97 @@ +# NVFLARE Auto-FL Skill + +This skill is intended to optimize any existing NVFLARE `job.py`. It is not +tied to the `research/auto-fl-research` bundle. + +## H100 Skill Trial Launcher + +For repeatable H100 validation, use +`scripts/launch_h100_skill_trial.sh`. The launcher prepares the environment +before the fresh coding agent starts: it clones the requested NVFLARE branch, +creates a Python 3.12 venv under the trial output directory, installs +job-local requirements when present, removes released `nvflare` packages, +installs the cloned repo editable, installs this skill into an isolated +`CODEX_HOME`, and starts Codex in `tmux` from the selected job directory. + +Default lightweight fixture: + +```bash +cd /scratch/hroth/Code/nvflare/ +AUTOFL_H100_BRANCH=codex/autofl-skill-v1 \ + skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh +``` + +That defaults to: + +```text +AUTOFL_H100_JOB=examples/hello-world/hello-pt/job.py +Prompt: Optimize ./job.py for accuracy in sim. +``` + +Run the bounded 10-candidate product UX smoke test: + +```bash +AUTOFL_H100_BRANCH= \ +AUTOFL_H100_JOB=examples/hello-world/hello-pt/job.py \ +AUTOFL_H100_PROMPT="Optimize ./job.py for accuracy in sim with a 10-candidate budget." \ +AUTOFL_H100_KILL_OLD=1 \ + skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh +``` + +Run the uncapped product UX trial; this should continue until you interrupt the +tmux session or stop the runner: + +```bash +AUTOFL_H100_BRANCH= \ +AUTOFL_H100_JOB=examples/hello-world/hello-pt/job.py \ +AUTOFL_H100_PROMPT="Optimize ./job.py for accuracy in sim." \ +AUTOFL_H100_KILL_OLD=1 \ + skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh +``` + +Run the same skill trial on any job in the cloned branch: + +```bash +AUTOFL_H100_BRANCH= \ +AUTOFL_H100_JOB=examples/advanced/sklearn-linear/job.py \ +AUTOFL_H100_REQUIREMENTS=auto \ + skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh +``` + +Run the heavier CIFAR research-style fixture only when that is the explicit +test target: + +```bash +AUTOFL_H100_BRANCH= \ +AUTOFL_H100_JOB=research/auto-fl-research/tasks/cifar10/job.py \ +AUTOFL_H100_PROMPT="Optimize ./job.py for accuracy in sim." \ + skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh +``` + +When a task-local `mutation_schema.yaml` is present, the deterministic runner +uses its comparison budget automatically. For the CIFAR-10 fixture this means +the H100 profile runs real CIFAR-10 with 8 clients, 20 rounds, 4 local epochs, +cross-site final evaluation, and the profile's timeout rather than the tiny +hello-pt synthetic smoke budget. + +Useful overrides: + +```bash +AUTOFL_H100_REPO_URL=git@github.com:/NVFlare.git +AUTOFL_H100_JOB=/absolute/path/to/job.py +AUTOFL_H100_JOB_CWD=/absolute/path/to/job-dir +AUTOFL_H100_REQUIREMENTS=/absolute/path/to/requirements.txt +AUTOFL_H100_REQUIREMENTS=none +AUTOFL_H100_NVFLARE_EXTRA=PT +AUTOFL_H100_PROMPT="Optimize ./job.py for AUC in prod with a 10-candidate budget." +AUTOFL_H100_KILL_OLD=1 +AUTOFL_H100_BOOTSTRAP_PYTHON=/path/to/python3.12 +``` + +Monitor without interrupting the agent: + +```bash +source "$(ls -td /scratch/hroth/Code/nvflare/pr4780-autofl-output/skill_trial_*/session.env | head -1)" +tmux capture-pane -pt "$SESSION" -S -120 +tail -f "$OUT/codex-tui.log" +``` diff --git a/skills/nvflare-autofl/SKILL.md b/skills/nvflare-autofl/SKILL.md index 29081b4e4a..02c70dbae7 100644 --- a/skills/nvflare-autofl/SKILL.md +++ b/skills/nvflare-autofl/SKILL.md @@ -24,17 +24,96 @@ hyperparameter tuning outside an NVFLARE job. Use this skill to optimize an existing NVFLARE `job.py` without asking the user to learn a new Auto-FL command tree. The user selects this skill, points to a -job, and states the objective, environment, and budget. NVFLARE provides the -deterministic campaign import, execution substrate, policy boundaries, -artifacts, and machine-readable contracts. The coding agent plans, edits allowed -files, runs candidates, compares results, and reports evidence. +job, and states the objective, environment, and optional budget. NVFLARE +provides the deterministic campaign import, execution substrate, policy +boundaries, artifacts, and machine-readable contracts. For simulation, the +coding agent invokes the product runner and monitors its code-owned state; the +runner owns candidate generation, execution, comparison, plots, and reports. +Manual agent edits are a fallback path, not the default product experience. -Before editing files, import the job deterministically: +Before any candidate work, import the job deterministically: ```bash python -m nvflare.app_common.autofl.job_importer ./job.py --metric --env --max-candidates --output autofl.yaml ``` +If the user did not specify a candidate cap, omit `--max-candidates` or leave it +unset in the review summary. Do not invent a default cap. + +For simulation campaigns, use the bundled deterministic campaign runner as the +code-owned loop. Include +`--max-candidates ` only when the user gave an explicit candidate budget: + +```bash +python "$CODEX_HOME/skills/nvflare-autofl/scripts/run_job_campaign.py" ./job.py --metric --env sim [--max-candidates ] +``` + +If the user did not specify a candidate cap, omit `--max-candidates`; the +runner will keep launching same-budget candidate attempts and refresh +`results.tsv`, `progress.png`, `.nvflare/autofl/campaign_state.json`, and +`autofl_report.md` after every finalized candidate until interrupted or blocked. +In this uncapped mode, do not ask the user whether to keep going. Report +checkpoint status as an observation, then continue monitoring or executing the +same runner while `final_response_allowed=false`. +If the job directory contains a task-local `mutation_schema.yaml`, treat its +comparison budget and mutation bounds as authoritative. The runner reads it +directly when present, so realistic tasks can supply fixed data/round/evaluation +contracts without requiring a long user prompt. +Candidates outside `mutation_schema.yaml` bounds are invalid proposals, not +campaign blockers. The runner must skip them before execution when possible and +continue with another same-budget candidate. The agent must not stop the +campaign, finalize a report, or start a new campaign merely because an invalid +generated proposal was skipped or crashed. + +Do not read or follow research harness prose, `program.md`, task profile +runbooks, `scripts/init_run.sh`, `.autoresearch` branch rules, or manual +research campaign instructions before starting the product runner. Those files +belong to the incubator workflow and can pull the agent back into an old +agent-driven loop. The product runner may read `mutation_schema.yaml`, +`autofl.yaml`, and the original `job.py`; use broader research instructions only +if the runner is unavailable or the user explicitly asks for the legacy research +campaign. + +Request escalated execution for the runner command because NVFLARE simulator +runs create local sockets that fail inside the restricted Codex sandbox. If a +runner command reports a sandbox/socket permission failure, treat it as an +infrastructure retry, not as a candidate result, and rerun the same command with +escalated execution. + +The runner owns deterministic import, baseline/candidate execution, candidate +counting, ledger updates, campaign state, progress plotting, and the concise +report. Do not produce a final response while the runner is active. After it +exits, read `.nvflare/autofl/campaign_state.json` and only finalize when +`final_response_allowed=true`. If an uncapped runner exits because of an invalid +generated candidate or other recoverable runner bug, fix the runner or schema +locally, then resume or relaunch the same requested optimization once. Do not +convert that into a completed campaign and do not start a different campaign. +During long simulations, monitor the active process plus the current +`autofl_runs//run.log`. A live process with no final ledger row is a +running candidate, not a reason to stop. If logs are temporarily quiet but CPU or +GPU use and the child process remain active, keep waiting. +For NVFLARE simulator runs, the server log can be quiet after it dispatches a +round while individual clients are still training. Before declaring a stall, +inspect the active simulator directory under `/tmp/nvflare/simulation/` and +check `site-*/log.txt` or `site-*/log_fl.txt` for epoch, finished-training, +download, or task-completion progress. If any client log or server aggregation +marker advances within the expected candidate runtime, continue the same +candidate; do not stop the runner, final-answer, or start a new campaign. +If the active NVFLARE simulator logs show a hard child-process connection +failure such as +`Failed to create connection to the child process in SimulatorClientRunner`, or +if a dispatched simulator round has no advancing server/client progress markers +past the configured no-progress watchdog timeout, do not start a new campaign and +do not produce a final report. Treat only that active candidate as crashed, +terminate the stuck `job.py` child if the runner has not already done so, +preserve the same `job.py`, `autofl.yaml`, metric, environment, ledger, and +comparison budget, then continue the same campaign. The product runner includes +these simulator-stall watchdogs; prefer letting it record the crash row, refresh +artifacts, and launch the next candidate. For legitimately long quiet tasks, +raise `--simulator-no-progress-timeout`, set +`AUTOFL_SIMULATOR_NO_PROGRESS_TIMEOUT_SECONDS`, or set +`simulator_no_progress_timeout_seconds` in the task profile. + Read `autofl.yaml` and show the user a concise campaign summary: - **Editable**: metric, environment, candidate budget, tunables, artifact @@ -57,33 +136,160 @@ specific fields before running candidates. - Do not edit outside `job.allowed_edit_paths`. - Preserve `budget.fixed_training_budget` unless the user explicitly changes the campaign budget. +- If the environment provides `PYTHON`, `VIRTUAL_ENV`, or a venv on `PATH`, + treat that prepared runtime as authoritative. Verify it, then use it for + import, validation, execution, metric extraction, plotting, and reporting. + Do not search for alternate interpreters or install dependencies unless the + user explicitly asks you to prepare the environment. +- Treat generated `autofl.yaml`, task-local `mutation_schema.yaml`, and + existing NVFLARE job/runtime configuration as authoritative. In the default + simulation product flow, do not require task-local prose profiles, campaign + branch setup, or research harness initialization before invoking the runner. - Use NVFLARE's existing execution surfaces: - For simulation, run the imported job with its configured `SimEnv`. - For POC and production, use standard `nvflare job submit`, `job wait`, `job download`, and related job/status commands. -- Record every candidate with a short name, changed files, diff summary, - run command, metric result, artifacts, and failure reason when applicable. +- Record every candidate in a ledger such as `results.tsv` with a short name, + changed files, diff summary, run command, metric result, artifacts, and + failure reason when applicable. - Prefer small, reviewable edits over broad rewrites. - Treat production as an available execution environment, but never bypass startup-kit authentication, site policy, or normal NVFLARE job submission. ## Candidate Loop +For simulation (`--env sim`), prefer `scripts/run_job_campaign.py` for both +explicitly capped and uncapped campaigns. Start the runner before inspecting or +editing task code beyond the deterministic import and `mutation_schema.yaml` +review. Use the manual loop below only when the runner is unavailable, when the +requested environment is POC/production, or when the user explicitly asks for +source-code mutations that the runner cannot express yet. + 1. Inspect `autofl.yaml`, the allowed files, and the current job behavior. 2. Propose a candidate change tied to one or more supported tunables or files. 3. Edit only allowed files. 4. Validate the job can still be imported and the fixed budget still matches. -5. Run the candidate through NVFLARE in the requested environment. +5. Run the candidate through NVFLARE in the requested environment with a unique + run name, log path, and artifact path. 6. Extract the requested metric from NVFLARE artifacts/logs. -7. Update the candidate ledger and keep the best reproducible candidate. -8. Stop when the budget is exhausted, the user stops the run, or results plateau. +7. Update the candidate ledger. Mark reviewed non-survivors as `discard`, + crashes as `crash`, the survivor as `keep`, and leave only unresolved active + rows as `candidate`. +8. Commit or checkpoint the ledger and surviving code change when a candidate is + kept, then refresh the code-owned campaign state before choosing the next + mutation axis. +9. Refresh `progress.png` from the ledger after every finalized batch, plateau + checkpoint, cap exhaustion, manual stop, or hard-blocker checkpoint. +10. Run the task-local campaign guard when present, for example + `scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json --format json`. + If no task-local guard exists, read the runner's + `.nvflare/autofl/campaign_state.json` state file. +11. Report the mutation hypothesis, changed files, commands run, observed + outcome, literature basis when relevant, run analysis, and next mutation. +12. Launch the next comparable candidate batch unless the code-owned state says + `final_response_allowed=true` or production policy blocks execution. + +## Autoresearch Operating Rule + +For uncapped campaigns, behave like the original Auto-FL research loop: after +setup and baseline, continue launching same-budget candidate batches until +manually interrupted. Do not ask whether to keep going. Do not produce a final +answer from your own judgment while the code-owned campaign state says +`final_response_allowed=false`. + +When `scripts/run_job_campaign.py` is running without `--max-candidates`, never +send Ctrl-C, interrupt the background terminal, or stop the runner because a +first sweep is complete, duplicate-cycle candidates started, a current best +looks clear, a plot/report exists, or no new obvious local axis remains. In +uncapped mode those are monitoring observations only. The user owns manual +interruption; the agent may inspect the ledger/state and report progress, but +must leave the runner active while `final_response_allowed=false`. +Also never interrupt an uncapped runner because it attempted, skipped, or +recorded an invalid candidate proposal. Invalid proposals should be filtered by +the runner and treated as product friction to repair while preserving the +long-running optimization intent. + +A kept improvement, refreshed plot, updated report, local commit, or encoded +`job.py` default is a checkpoint, not completion. After encoding or validating a +best-so-far default, run the watchdog, update the ledger/report, and launch the +next same-budget candidate batch. Do not final-answer after the first baseline, +first successful candidate, first improvement, first local commit, first report +update, first `progress.png`, first plateau check, or first default update. + +Treat the campaign state as authoritative: + +- If `.autoresearch/campaign_state.json` exists, read it before any final + response. +- If `scripts/campaign_guard.py` exists, run it after every checkpoint and + before any final response. +- If the state has `final_response_allowed=false`, execute `next_action` + immediately; the skill text is only the interaction layer. +- If the state has `next_action=finalize_pending_candidates`, finalize reviewed + candidate rows and rerun the guard. +- If the state has `next_action=run_literature_loop`, run the literature loop + and launch source-backed candidates. +- If the state has `next_action=launch_next_candidate_batch`, choose a safe + same-budget axis and launch the next batch. + +After every finalized batch, run the available plateau or progress watchdog when +the task provides one. If it recommends `continue`, refresh `progress.png` and +keep iterating locally. If it recommends a literature or exploration mode, record +that decision in the ledger/report, refresh `progress.png`, and launch the top +compatible candidate batch next. If no non-duplicate safe local axis remains, +switch mode rather than stopping: broaden the search within `autofl.yaml`, run a +literature-inspired proposal pass, revisit unresolved-but-safe tunables, or let +the deterministic uncapped runner continue its generated candidate stream. Stop +only when the user manually interrupts, an explicit candidate cap is exhausted, +production policy blocks execution, or repeated failures share a hard root cause +that prevents safe comparable runs. +If the current process has already stopped but the user did not ask to stop, do +not leave the campaign in a terminal state. Inspect the campaign state and +ledger, fix the recoverable cause, and continue the same optimization with the +same `job.py`, `autofl.yaml`, metric, environment, and comparison budget. +If a runner or candidate process stalls on a known NVFLARE simulator child +connection timeout or a configured simulator no-progress watchdog, recover the +active candidate in place. Do not kick off a new campaign directory, new prompt, +new objective, or new baseline unless the human explicitly requests that reset. + +## Candidate Caps + +Campaigns are uncapped by default. If the user says "optimize this job" without +an explicit candidate budget, continue the campaign until manually interrupted +or blocked. Do not stop after the first baseline, first batch, first successful +candidate, first kept improvement, first local commit, or first plateau +checkpoint. Do not stop after a first sweep of available CLI tunables; in +uncapped mode repeating, broadening, or generated same-budget candidates are +valid continued campaign work. Progress reports in uncapped mode must not be +phrased as "should I continue?" decisions; the answer is continue unless the +user explicitly interrupts or the code-owned state permits finalization. +Do not invent a replacement campaign or new objective after a recoverable +failure. Keep the current campaign identity and artifacts coherent unless the +human explicitly requests a new campaign. + +If the user provides an `N`-candidate budget, count up to `N` comparable +candidate attempts after the baseline. Do not count deterministic import, +validation, smoke runs, plotting, reporting, the baseline, or +infrastructure-only retries caused by sandbox/socket/runtime setup. Count a real +candidate crash once the candidate run starts under the intended execution +environment. + +Treat plateau as a decision checkpoint, not an automatic stop. Summarize the +plateau in the running report, refresh `progress.png`, run the campaign guard or +read the runner's `.nvflare/autofl/campaign_state.json`, choose the returned +next mode, and continue unless the state reports `final_response_allowed=true`. -## Final Report +## Stop Handling -End with: +Only produce a final answer for a campaign when the code-owned campaign state +reports `final_response_allowed=true`, for example because the user manually +stopped it, an explicit cap is exhausted, production policy blocks execution, or +a hard safety/runtime blocker prevents further comparable runs. At that point, +end the search with: -- Best candidate and metric improvement. -- Baseline versus candidate leaderboard. -- Files changed and why. -- Artifacts and commands needed to reproduce the best result. -- Unresolved limitations or production review items. +- finalized `results.tsv` or equivalent candidate ledger; +- refreshed `progress.png` or an explicit explanation if plotting is impossible; +- concise report or run summary with baseline, best score, leaderboard, files + changed, artifacts, command provenance, failures, product friction, and + reproduction commands; +- absolute paths to `autofl.yaml`, `results.tsv`, `progress.png`, campaign state, + and any report artifact in the final answer. diff --git a/skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh b/skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh new file mode 100755 index 0000000000..34ef1de0e1 --- /dev/null +++ b/skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Launch a fresh Codex Auto-FL skill UX trial on the H100 host. +# +# The launcher prepares NVFlare and job dependencies before the agent starts. +# The agent should test the product skill behavior on an existing job.py, not +# rediscover Python, package, or skill-install setup details. + +BASE=${AUTOFL_H100_BASE:-/scratch/hroth/Code/nvflare} +OUTPUT_BASE=${AUTOFL_H100_OUTPUT_BASE:-${BASE}/pr4780-autofl-output} +REPO_URL=${AUTOFL_H100_REPO_URL:-git@github.com:holgerroth/NVFlare.git} +BRANCH=${AUTOFL_H100_BRANCH:-codex/autofl-skill-v1} +JOB_PATH=${AUTOFL_H100_JOB:-examples/hello-world/hello-pt/job.py} +BOOTSTRAP_PYTHON=${AUTOFL_H100_BOOTSTRAP_PYTHON:-/scratch/hroth/Code/auto-fl/.venv312/bin/python} +SESSION_PREFIX=${AUTOFL_H100_SESSION_PREFIX:-pr4780-autofl-skill} +KILL_OLD=${AUTOFL_H100_KILL_OLD:-0} +SKIP_DEPS=${AUTOFL_H100_SKIP_DEPS:-0} +NVFLARE_EXTRA=${AUTOFL_H100_NVFLARE_EXTRA:-PT} +REQUIREMENTS=${AUTOFL_H100_REQUIREMENTS:-auto} +PARALLEL_CANDIDATES=${AUTOFL_H100_PARALLEL_CANDIDATES:-4} +CUDA_VISIBLE_DEVICES_VALUE=${AUTOFL_H100_CUDA_VISIBLE_DEVICES:-0} +OVERLAY_TGZ=${AUTOFL_H100_OVERLAY_TGZ:-} + +TS=${AUTOFL_H100_TS:-$(date +%Y%m%d_%H%M%S)} +REPO=${AUTOFL_H100_REPO:-${BASE}/pr4780-autofl-skill-trial-${TS}} +OUT=${AUTOFL_H100_OUT:-${OUTPUT_BASE}/skill_trial_${TS}} +CODEX_TRIAL=${AUTOFL_H100_CODEX_HOME:-${OUT}/codex_home} +VENV_DIR=${AUTOFL_H100_VENV:-${OUT}/venv} +SESSION=${AUTOFL_H100_SESSION:-${SESSION_PREFIX}-${TS}} + +find_bootstrap_python() { + if [[ -x "${BOOTSTRAP_PYTHON}" ]]; then + printf '%s\n' "${BOOTSTRAP_PYTHON}" + return + fi + if command -v python3.12 >/dev/null 2>&1; then + command -v python3.12 + return + fi + echo "ERROR: no Python 3.12 bootstrap interpreter found." >&2 + echo "Set AUTOFL_H100_BOOTSTRAP_PYTHON to a Python 3.12 executable." >&2 + exit 2 +} + +resolve_path() { + local base="$1" + local path="$2" + if [[ "${path}" = /* ]]; then + printf '%s\n' "${path}" + else + printf '%s\n' "${base}/${path}" + fi +} + +install_requirements_without_nvflare() { + local requirements_file="$1" + local filtered_requirements="${OUT}/requirements.filtered.txt" + + awk ' + /^[[:space:]]*($|#)/ { print; next } + /^[[:space:]]*nvflare([[:space:]\[<=>!~].*)?$/ { next } + { print } + ' "${requirements_file}" > "${filtered_requirements}" + + if grep -Eq '^[[:space:]]*[^#[:space:]]' "${filtered_requirements}"; then + "${PYTHON}" -m pip install -r "${filtered_requirements}" + fi +} + +if [[ "${KILL_OLD}" == "1" ]]; then + tmux ls 2>/dev/null | awk -F: -v prefix="${SESSION_PREFIX}" '$1 ~ "^" prefix {print $1}' | while read -r old_session; do + [[ -n "${old_session}" ]] && tmux kill-session -t "${old_session}" || true + done +fi + +mkdir -p "${BASE}" "${OUTPUT_BASE}" "${OUT}" + +if [[ -e "${REPO}" ]]; then + echo "ERROR: repo path already exists: ${REPO}" >&2 + exit 2 +fi + +git clone --branch "${BRANCH}" "${REPO_URL}" "${REPO}" + +if [[ -n "${OVERLAY_TGZ}" ]]; then + if [[ ! -f "${OVERLAY_TGZ}" ]]; then + echo "ERROR: overlay tarball not found: ${OVERLAY_TGZ}" >&2 + exit 2 + fi + tar -xzf "${OVERLAY_TGZ}" -C "${REPO}" +fi + +if [[ ! -d "${REPO}/skills/nvflare-autofl" ]]; then + echo "ERROR: bundled skill not found: ${REPO}/skills/nvflare-autofl" >&2 + exit 2 +fi + +JOB_ABS=$(resolve_path "${REPO}" "${JOB_PATH}") +if [[ ! -f "${JOB_ABS}" ]]; then + echo "ERROR: job.py not found: ${JOB_ABS}" >&2 + exit 2 +fi + +if [[ -n "${AUTOFL_H100_JOB_CWD:-}" ]]; then + JOB_CWD=$(resolve_path "${REPO}" "${AUTOFL_H100_JOB_CWD}") +else + JOB_CWD=$(dirname "${JOB_ABS}") +fi +if [[ ! -d "${JOB_CWD}" ]]; then + echo "ERROR: job cwd not found: ${JOB_CWD}" >&2 + exit 2 +fi + +PY_BOOTSTRAP=$(find_bootstrap_python) +PYTHON="${VENV_DIR}/bin/python" + +if [[ "${SKIP_DEPS}" != "1" ]]; then + "${PY_BOOTSTRAP}" -m venv "${VENV_DIR}" + "${PYTHON}" -m pip install --upgrade pip + + if [[ "${REQUIREMENTS}" == "auto" ]]; then + REQUIREMENTS="${JOB_CWD}/requirements.txt" + elif [[ "${REQUIREMENTS}" != "none" ]]; then + REQUIREMENTS=$(resolve_path "${REPO}" "${REQUIREMENTS}") + fi + + if [[ "${REQUIREMENTS}" != "none" && -f "${REQUIREMENTS}" ]]; then + install_requirements_without_nvflare "${REQUIREMENTS}" + fi + + "${PYTHON}" -m pip uninstall -y nvflare nvflare-nightly >/dev/null 2>&1 || true + if [[ -n "${NVFLARE_EXTRA}" ]]; then + "${PYTHON}" -m pip install -e "${REPO}[${NVFLARE_EXTRA}]" + else + "${PYTHON}" -m pip install -e "${REPO}" + fi +fi + +"${PYTHON}" -c 'import sys; assert sys.version_info[:2] == (3, 12), sys.version; print(sys.executable)' +"${PYTHON}" -c 'import msgpack, nvflare; print("nvflare", nvflare.__file__)' + +mkdir -p "${CODEX_TRIAL}/skills" +cp "${HOME}/.codex/config.toml" "${CODEX_TRIAL}/config.toml" +if [[ -f "${HOME}/.codex/auth.json" ]]; then + ln -sf "${HOME}/.codex/auth.json" "${CODEX_TRIAL}/auth.json" +fi +chmod 700 "${CODEX_TRIAL}" 2>/dev/null || true + +cp -R "${REPO}/skills/nvflare-autofl" "${CODEX_TRIAL}/skills/nvflare-autofl" + +cat >> "${CODEX_TRIAL}/config.toml" < "${OUT}/initial_prompt.txt" < "${OUT}/session.env" <> '${OUT}/codex-tui.log'" + +cat < argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("job", help="NVFlare job.py to optimize") + parser.add_argument("--metric", default="accuracy") + parser.add_argument("--mode", choices=["max", "min"], default="max") + parser.add_argument("--env", dest="target_env", choices=["sim"], default="sim") + parser.add_argument( + "--max-candidates", + type=int, + help="optional candidate cap; omit to continue until interrupted or blocked", + ) + parser.add_argument("--autofl-yaml", default="autofl.yaml") + parser.add_argument("--results", default="results.tsv") + parser.add_argument("--state", default=".nvflare/autofl/campaign_state.json") + parser.add_argument("--progress", default="progress.png") + parser.add_argument("--report", default="autofl_report.md") + parser.add_argument("--output-root", default="autofl_runs") + parser.add_argument("--base-args", default="", help="extra job args applied to baseline and all candidates") + parser.add_argument("--timeout", type=int, default=900) + parser.add_argument( + "--simulator-no-progress-timeout", + type=int, + default=int( + os.environ.get("AUTOFL_SIMULATOR_NO_PROGRESS_TIMEOUT_SECONDS", DEFAULT_SIMULATOR_NO_PROGRESS_TIMEOUT) + ), + help=( + "candidate-level simulator no-progress timeout in seconds; set 0 to disable. " + "This is separate from the full run timeout and only applies after progress markers appear." + ), + ) + parser.add_argument("--python", default=os.environ.get("PYTHON") or sys.executable) + parser.add_argument("--prefer-synthetic", action=argparse.BooleanOptionalAction, default=True) + parser.add_argument("--synthetic-train-size", type=int, default=2048) + parser.add_argument("--synthetic-test-size", type=int, default=256) + return parser.parse_args(argv) + + +def terminate_process(process: subprocess.Popen) -> None: + if process.poll() is not None: + return + if os.name != "nt": + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + except Exception: + process.terminate() + else: + process.terminate() + + try: + process.wait(timeout=10) + return + except subprocess.TimeoutExpired: + pass + + if os.name != "nt": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return + except Exception: + process.kill() + else: + process.kill() + + +def recent_text(path: Path, limit: int = SIMULATOR_STALL_LOG_LIMIT) -> str: + try: + with path.open("rb") as f: + try: + f.seek(-limit, os.SEEK_END) + except OSError: + f.seek(0) + return f.read().decode("utf-8", errors="replace") + except OSError: + return "" + + +def detect_nvflare_simulator_stall(sim_root: Path) -> Optional[str]: + if not sim_root.exists(): + return None + + log_paths = [ + sim_root / "server" / "log_fl.txt", + sim_root / "server" / "log.txt", + sim_root / "server" / "error_log.txt", + ] + for path in log_paths: + text = recent_text(path) + if not text: + continue + for pattern in SIMULATOR_STALL_PATTERNS: + if pattern in text: + return f"{pattern} in {path}" + return None + + +def simulator_stall_message(simulator_stall_roots: Sequence[Path]) -> Optional[str]: + for root in simulator_stall_roots: + message = detect_nvflare_simulator_stall(root) + if message: + return message + return None + + +def simulator_progress_signature(sim_root: Path) -> str: + if not sim_root.exists(): + return "" + + log_paths = [ + sim_root / "server" / "log_fl.txt", + sim_root / "server" / "log.txt", + *sorted(sim_root.glob("site-*/log_fl.txt")), + *sorted(sim_root.glob("site-*/log.txt")), + ] + markers: List[str] = [] + for path in log_paths: + text = recent_text(path, limit=SIMULATOR_PROGRESS_LOG_LIMIT) + if not text: + continue + for line in text.splitlines(): + line_lower = line.lower() + if any(pattern.lower() in line_lower for pattern in SIMULATOR_PROGRESS_PATTERNS): + markers.append(f"{path.relative_to(sim_root)}:{line}") + return "\n".join(markers[-200:]) + + +def simulator_progress_signature_for_roots(simulator_stall_roots: Sequence[Path]) -> str: + markers = [] + for root in simulator_stall_roots: + signature = simulator_progress_signature(root) + if signature: + markers.append(f"{root}:\n{signature}") + return "\n".join(markers) + + +def simulator_partial_aggregation_signature(sim_root: Path) -> str: + signatures = [] + for relative_log_path in ("server/log_fl.txt", "server/simulate_job/log_fl.txt"): + path = sim_root / relative_log_path + text = recent_text(path, SIMULATOR_PROGRESS_LOG_LIMIT) + if not text: + continue + for line in reversed(text.splitlines()): + match = SIMULATOR_AGGREGATION_RE.search(line) + if not match: + continue + received = int(match.group(1)) + expected = int(match.group(2)) + if received < expected: + signatures.append(f"{path.relative_to(sim_root)}:{line}") + break + return "\n".join(signatures) + + +def simulator_partial_aggregation_signature_for_roots(simulator_stall_roots: Sequence[Path]) -> str: + markers = [] + for root in simulator_stall_roots: + signature = simulator_partial_aggregation_signature(root) + if signature: + markers.append(f"{root}:\n{signature}") + return "\n".join(markers) + + +def run( + argv: Sequence[str], + cwd: Path, + timeout: int, + log_path: Path, + simulator_stall_roots: Sequence[Path] = (), + stall_check_interval: float = 5.0, + simulator_no_progress_timeout: int = DEFAULT_SIMULATOR_NO_PROGRESS_TIMEOUT, +) -> Tuple[int, str, float]: + started = time.monotonic() + next_stall_check = started + last_progress_check = started + last_progress_seen = started + last_progress_signature = "" + last_partial_aggregation_seen = started + last_partial_aggregation_signature = "" + log_path.parent.mkdir(parents=True, exist_ok=True) + chunks: List[str] = [] + with log_path.open("w", encoding="utf-8") as log_file: + process = subprocess.Popen( + argv, + cwd=str(cwd), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=os.name != "nt", + ) + assert process.stdout is not None + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ) + timed_out = False + stall_message = "" + while True: + now = time.monotonic() + if timeout and now - started > timeout: + timed_out = True + terminate_process(process) + if simulator_stall_roots and now >= next_stall_check: + stall_message = simulator_stall_message(simulator_stall_roots) or "" + next_stall_check = now + stall_check_interval + if stall_message: + terminate_process(process) + elif simulator_no_progress_timeout: + partial_aggregation_signature = simulator_partial_aggregation_signature_for_roots( + simulator_stall_roots + ) + if ( + partial_aggregation_signature + and partial_aggregation_signature != last_partial_aggregation_signature + ): + last_partial_aggregation_signature = partial_aggregation_signature + last_partial_aggregation_seen = now + elif ( + last_partial_aggregation_signature + and now - last_partial_aggregation_seen > simulator_no_progress_timeout + ): + stall_message = ( + "partial simulator aggregation made no server-side progress for " + f"{int(now - last_partial_aggregation_seen)}s: {last_partial_aggregation_signature}" + ) + terminate_process(process) + progress_signature = simulator_progress_signature_for_roots(simulator_stall_roots) + if stall_message: + pass + elif progress_signature and progress_signature != last_progress_signature: + last_progress_signature = progress_signature + last_progress_seen = now + elif ( + last_progress_signature + and now - last_progress_seen > simulator_no_progress_timeout + and now - last_progress_check >= stall_check_interval + ): + stall_message = ( + f"no simulator progress markers changed for {int(now - last_progress_seen)}s " + f"across {', '.join(str(root) for root in simulator_stall_roots)}" + ) + terminate_process(process) + last_progress_check = now + events = selector.select(timeout=0.2) + for key, _ in events: + chunk = key.fileobj.readline() + if chunk: + chunks.append(chunk) + log_file.write(chunk) + log_file.flush() + if process.poll() is not None: + remainder = process.stdout.read() + if remainder: + chunks.append(remainder) + log_file.write(remainder) + log_file.flush() + break + selector.close() + if timed_out: + timeout_msg = f"\nTIMEOUT after {timeout}s\n" + chunks.append(timeout_msg) + log_file.write(timeout_msg) + log_file.flush() + return 124, "".join(chunks), time.monotonic() - started + if stall_message: + stall_text = f"\nSIMULATOR_STALL: {stall_message}\n" + chunks.append(stall_text) + log_file.write(stall_text) + log_file.flush() + return SIMULATOR_STALL_EXIT_CODE, "".join(chunks), time.monotonic() - started + return process.returncode or 0, "".join(chunks), time.monotonic() - started + + +def run_allow_timeout( + argv: Sequence[str], + cwd: Path, + timeout: int, + log_path: Path, + simulator_stall_roots: Sequence[Path] = (), + simulator_no_progress_timeout: int = DEFAULT_SIMULATOR_NO_PROGRESS_TIMEOUT, +) -> Tuple[int, str, float]: + try: + return run( + argv, + cwd, + timeout, + log_path, + simulator_stall_roots=simulator_stall_roots, + simulator_no_progress_timeout=simulator_no_progress_timeout, + ) + except subprocess.TimeoutExpired as e: + output = e.stdout or "" + if isinstance(output, bytes): + output = output.decode("utf-8", errors="replace") + log_path.parent.mkdir(parents=True, exist_ok=True) + log_path.write_text(output + f"\nTIMEOUT after {timeout}s\n", encoding="utf-8") + return 124, output, float(timeout) + + +def read_yaml(path: Path) -> Dict[str, Any]: + if yaml is None: + raise RuntimeError("PyYAML is required to read autofl.yaml") + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + + +def write_json(path: Path, data: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def resolve_output_path(cwd: Path, value: str) -> Path: + path = Path(value) + if path.is_absolute(): + return path + return cwd / path + + +def extract_result_dir(output: str) -> Optional[Path]: + patterns = [ + r"Result can be found in\s*:\s*(?P\S+)", + r"Results:\s*(?P\S+)", + r"result_dir=(?P\S+)", + ] + for pattern in patterns: + match = re.search(pattern, output) + if match: + return Path(match.group("path")).expanduser() + return None + + +def find_metric_value(payload: Any, metric: str) -> Optional[float]: + if isinstance(payload, dict): + for key in ("final_aggregated_metrics", "best_metrics", "metrics"): + value = metric_from_list(payload.get(key), metric) + if value is not None: + return value + if metric in payload and isinstance(payload[metric], (int, float)): + return float(payload[metric]) + for value in payload.values(): + score = find_metric_value(value, metric) + if score is not None: + return score + elif isinstance(payload, list): + value = metric_from_list(payload, metric) + if value is not None: + return value + for item in payload: + score = find_metric_value(item, metric) + if score is not None: + return score + return None + + +def metric_from_list(items: Any, metric: str) -> Optional[float]: + if not isinstance(items, list): + return None + for item in items: + if not isinstance(item, dict): + continue + if item.get("name") == metric and isinstance(item.get("value"), (int, float)): + return float(item["value"]) + return None + + +def extract_score(artifact_root: Path, metric: str) -> Optional[float]: + metric_files = list(artifact_root.glob("**/metrics_summary.json")) + list( + artifact_root.glob("**/cross_val_results.json") + ) + for path in metric_files: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception: + continue + score = find_metric_value(payload, metric) + if score is not None: + return score + + number_patterns = [ + rf"{re.escape(metric)}[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)", + r"Accuracy of the network[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)", + ] + for path in artifact_root.glob("**/*.log"): + try: + text = path.read_text(encoding="utf-8", errors="replace") + except Exception: + continue + for pattern in number_patterns: + matches = re.findall(pattern, text, flags=re.IGNORECASE) + if matches: + return float(matches[-1]) + return None + + +def is_sandbox_socket_failure(output: str) -> bool: + text = output.lower() + return ( + "permissionerror" in text + and ("operation not permitted" in text or "[errno 1]" in text) + and ("socket" in text or "sock" in text) + ) + + +def is_nvflare_simulator_stall(output: str) -> bool: + return "SIMULATOR_STALL:" in output + + +def collect_artifacts(result_dir: Optional[Path], output_root: Path, name: str, log_path: Path) -> Path: + dest = output_root / name / "simulation" + run_log = output_root / name / "run.log" + if dest.exists(): + shutil.rmtree(dest) + if result_dir and result_dir.exists(): + shutil.copytree(result_dir, dest) + shutil.rmtree(result_dir, ignore_errors=True) + else: + dest.mkdir(parents=True, exist_ok=True) + if log_path.resolve() != run_log.resolve(): + shutil.copy2(log_path, run_log) + return dest + + +def job_help(python: str, job: Path, cwd: Path) -> str: + process = subprocess.run( + [python, str(job), "--help"], + cwd=str(cwd), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + return process.stdout + + +def supports_flag(help_text: str, flag: str) -> bool: + return flag in help_text + + +def mutable_arg_specs(schema: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + specs = schema.get("mutable_args") + return specs if isinstance(specs, dict) else {} + + +def candidate_arg_values(candidate_args: Sequence[str]) -> Dict[str, Any]: + values: Dict[str, Any] = {} + idx = 0 + while idx < len(candidate_args): + raw = candidate_args[idx] + if not raw.startswith("--"): + idx += 1 + continue + name = raw[2:].replace("-", "_") + if idx + 1 >= len(candidate_args) or candidate_args[idx + 1].startswith("--"): + values[name] = True + idx += 1 + else: + values[name] = candidate_args[idx + 1] + idx += 2 + return values + + +def coerce_schema_value(value: Any, spec: Dict[str, Any]) -> Any: + value_type = spec.get("type") + if value_type == "int": + return int(value) + if value_type == "float": + return float(value) + if value_type == "bool": + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + return str(value) + + +def candidate_args_allowed(candidate_args: Sequence[str], schema: Dict[str, Any]) -> Tuple[bool, str]: + specs = mutable_arg_specs(schema) + if not specs: + return True, "" + + for name, value in candidate_arg_values(candidate_args).items(): + spec = specs.get(name) + if not isinstance(spec, dict): + continue + try: + coerced = coerce_schema_value(value, spec) + except (TypeError, ValueError) as e: + return False, f"{name}={value!r} cannot be parsed as {spec.get('type')}: {e}" + + choices = spec.get("choices") + if choices is not None and coerced not in choices: + return False, f"{name}={coerced!r} is not in allowed choices {choices!r}" + minimum = spec.get("min") + if minimum is not None and coerced < minimum: + return False, f"{name}={coerced!r} is below schema min {minimum!r}" + maximum = spec.get("max") + if maximum is not None and coerced > maximum: + return False, f"{name}={coerced!r} is above schema max {maximum!r}" + + return True, "" + + +def load_mutation_schema(cwd: Path) -> Dict[str, Any]: + path = cwd / "mutation_schema.yaml" + if not path.exists(): + return {} + if yaml is None: + raise RuntimeError("PyYAML is required to read mutation_schema.yaml") + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + + +def h100_comparison_budget(schema: Dict[str, Any]) -> Dict[str, Any]: + return ( + schema.get("comparison_budget_args", {}).get("h100_default_candidate_budget", {}) + if isinstance(schema.get("comparison_budget_args"), dict) + else {} + ) + + +def fixed_within_campaign(schema: Dict[str, Any]) -> set: + values = [] + comparison = schema.get("comparison_budget_args") + if isinstance(comparison, dict): + values = comparison.get("fixed_within_campaign") or [] + return set(values) if isinstance(values, list) else set() + + +def build_profile_args(schema: Dict[str, Any], help_text: str) -> List[str]: + budget = h100_comparison_budget(schema) + args: List[str] = [] + for field, cli_name in PROFILE_BUDGET_TO_CLI.items(): + value = budget.get(field) + if value is not None and supports_flag(help_text, f"--{cli_name}"): + args.extend([f"--{cli_name}", str(value)]) + if budget.get("cross_site_eval") and supports_flag(help_text, "--cross_site_eval"): + args.append("--cross_site_eval") + return args + + +def build_fixed_args(config: Dict[str, Any], help_text: str, schema: Dict[str, Any]) -> List[str]: + fixed = config.get("budget", {}).get("fixed_training_budget", {}) or {} + profile_budget = h100_comparison_budget(schema) + profile_cli_names = { + cli_name for field, cli_name in PROFILE_BUDGET_TO_CLI.items() if profile_budget.get(field) is not None + } + args: List[str] = [] + for field, cli_name in FIXED_BUDGET_TO_CLI.items(): + if cli_name in profile_cli_names: + continue + value = fixed.get(field) + if value is not None and supports_flag(help_text, f"--{cli_name}"): + args.extend([f"--{cli_name}", str(value)]) + return args + + +def build_base_args(args: argparse.Namespace, help_text: str, schema: Dict[str, Any]) -> List[str]: + base = shlex.split(args.base_args) + profile_args = build_profile_args(schema, help_text) + if profile_args: + base.extend(profile_args) + if args.prefer_synthetic and supports_flag(help_text, "--synthetic_data"): + if "--synthetic_data" not in base: + base.append("--synthetic_data") + if supports_flag(help_text, "--train_size") and "--train_size" not in base: + base.extend(["--train_size", str(args.synthetic_train_size)]) + if supports_flag(help_text, "--test_size") and "--test_size" not in base: + base.extend(["--test_size", str(args.synthetic_test_size)]) + return base + + +def suggested_arg_defaults(config: Dict[str, Any]) -> Dict[str, Any]: + suggested = config.get("search_space", {}).get("suggested", {}) or {} + defaults = {} + for name, spec in suggested.items(): + if isinstance(spec, dict) and "default" in spec: + defaults[name] = spec["default"] + return defaults + + +def candidate_plan( + config: Dict[str, Any], + help_text: str, + max_candidates: Optional[int], + schema: Optional[Dict[str, Any]] = None, +) -> Iterable[JobRun]: + defaults = suggested_arg_defaults(config) + candidates: List[JobRun] = [] + seen_args = set() + fixed_fields = fixed_within_campaign(schema or {}) + + def can_mutate(name: str) -> bool: + return name not in fixed_fields + + def make_candidate(name: str, candidate_args: List[str], description: str) -> Optional[JobRun]: + allowed, _ = candidate_args_allowed(candidate_args, schema or {}) + if not allowed: + return None + key = tuple(candidate_args) + if key in seen_args: + return None + seen_args.add(key) + return JobRun(name=name, args=candidate_args, description=description) + + def add(name: str, candidate_args: List[str], description: str) -> None: + if max_candidates is not None and len(candidates) >= max_candidates: + return + candidate = make_candidate(name, candidate_args, description) + if candidate is not None: + candidates.append(candidate) + + if can_mutate("aggregator") and supports_flag(help_text, "--aggregator"): + for value in ["default", "fedavg", "fedavgm", "fedadam", "scaffold"]: + add(f"aggregator_{value}", ["--aggregator", value], f"aggregator={value}") + if can_mutate("fedproxloss_mu") and supports_flag(help_text, "--fedproxloss_mu"): + add( + "fedprox_mu_1e-5", + ["--aggregator", "weighted", "--fedproxloss_mu", "1e-5"], + "aggregator=weighted fedproxloss_mu=1e-5", + ) + add( + "fedprox_mu_1e-4", + ["--aggregator", "weighted", "--fedproxloss_mu", "1e-4"], + "aggregator=weighted fedproxloss_mu=1e-4", + ) + + if can_mutate("aggregation_epochs") and supports_flag(help_text, "--aggregation_epochs"): + default = int(defaults.get("aggregation_epochs") or 4) + for value in [1, 2, 3, 4, 6, 8]: + if value != default: + add(f"aggregation_epochs_{value}", ["--aggregation_epochs", str(value)], f"aggregation_epochs={value}") + + if can_mutate("local_train_steps") and supports_flag(help_text, "--local_train_steps"): + for value in [50, 100, 200, 400]: + add(f"local_train_steps_{value}", ["--local_train_steps", str(value)], f"local_train_steps={value}") + + if can_mutate("lr") and supports_flag(help_text, "--lr"): + default = float(defaults.get("lr") or 0.05) + for value in [default / 4, default / 2, default * 2, default * 4]: + value_text = f"{value:.6g}" + add(f"lr_{value_text.replace('.', 'p').replace('-', 'm')}", ["--lr", value_text], f"lr={value_text}") + + if can_mutate("momentum") and supports_flag(help_text, "--momentum"): + for value in [0.0, 0.5, 0.8, 0.95]: + add( + f"momentum_{str(value).replace('.', 'p')}", + ["--momentum", str(value)], + f"momentum={value}", + ) + + if can_mutate("weight_decay") and supports_flag(help_text, "--weight_decay"): + for value in ["1e-5", "1e-4", "5e-4"]: + add(f"weight_decay_{value.replace('-', 'm')}", ["--weight_decay", value], f"weight_decay={value}") + + if can_mutate("batch_size") and supports_flag(help_text, "--batch_size"): + default = int(defaults.get("batch_size") or 16) + values = [max(1, default // 2), default * 2, default * 4, max(1, default // 4), 24, 40, 64, 96, 128, 256] + for value in values: + if value != default: + add(f"batch_size_{value}", ["--batch_size", str(value)], f"batch_size={value}") + + if can_mutate("epochs") and supports_flag(help_text, "--epochs"): + for value in [1, 2, 3, 4, 5]: + add(f"epochs_{value}", ["--epochs", str(value)], f"epochs={value}") + + if can_mutate("num_workers") and supports_flag(help_text, "--num_workers"): + for value in [0, 1, 2, 4]: + add(f"num_workers_{value}", ["--num_workers", str(value)], f"num_workers={value}") + + if supports_flag(help_text, "--client_memory_gc_rounds"): + add("client_memory_gc_1", ["--client_memory_gc_rounds", "1"], "client_memory_gc_rounds=1") + + if not candidates: + add("rerun", [], "repeat baseline command to test campaign plumbing") + + if max_candidates is not None: + return candidates[:max_candidates] + + def uncapped() -> Iterable[JobRun]: + for template in candidates: + yield JobRun(name=template.name, args=list(template.args), description=template.description) + + idx = 1 + batch_default = int(defaults.get("batch_size") or 16) + while True: + generated = False + if can_mutate("batch_size") and supports_flag(help_text, "--batch_size"): + # Walk a broad conservative range before repeats so uncapped + # campaigns keep doing comparable, reviewable work for hours. + value = 1 + ((batch_default + idx * 7) % 512) + candidate_args = ["--batch_size", str(value)] + candidate = make_candidate( + f"batch_size_auto_{value}", + candidate_args, + f"batch_size={value}", + ) + if value != batch_default and candidate is not None: + yield candidate + generated = True + + if not generated and can_mutate("aggregation_epochs") and supports_flag(help_text, "--aggregation_epochs"): + value = 1 + ((idx - 1) % 8) + candidate_args = ["--aggregation_epochs", str(value)] + candidate = make_candidate( + f"aggregation_epochs_auto_{value}", + candidate_args, + f"aggregation_epochs={value}", + ) + if candidate is not None: + yield candidate + generated = True + + if not generated and can_mutate("lr") and supports_flag(help_text, "--lr"): + value = 10 ** (-4 + ((idx - 1) % 25) / 10) + value_text = f"{value:.6g}" + candidate_args = ["--lr", value_text] + candidate = make_candidate( + f"lr_auto_{value_text.replace('.', 'p').replace('-', 'm')}", + candidate_args, + f"lr={value_text}", + ) + if candidate is not None: + yield candidate + generated = True + + if not generated and can_mutate("epochs") and supports_flag(help_text, "--epochs"): + value = 1 + ((idx - 1) % 20) + candidate_args = ["--epochs", str(value)] + candidate = make_candidate(f"epochs_auto_{value}", candidate_args, f"epochs={value}") + if candidate is not None: + yield candidate + generated = True + + if not generated and can_mutate("num_workers") and supports_flag(help_text, "--num_workers"): + value = (idx - 1) % 9 + candidate_args = ["--num_workers", str(value)] + candidate = make_candidate(f"num_workers_auto_{value}", candidate_args, f"num_workers={value}") + if candidate is not None: + yield candidate + generated = True + + if not generated: + template = candidates[(idx - 1) % len(candidates)] + cycle = (idx - 1) // len(candidates) + 2 + yield JobRun( + name=f"{template.name}_repeat_{cycle:04d}", + args=list(template.args), + description=f"{template.description}; repeat_cycle={cycle}", + ) + idx += 1 + + return uncapped() + + +def remove_known_result_dir(config: Dict[str, Any]) -> None: + recipe_args = config.get("job", {}).get("recipe_args", {}) or {} + name = recipe_args.get("name", {}).get("value") if isinstance(recipe_args.get("name"), dict) else None + if isinstance(name, str) and name: + shutil.rmtree(Path("/tmp/nvflare/simulation") / name, ignore_errors=True) + + +def run_job( + run_def: JobRun, + *, + python: str, + job: Path, + cwd: Path, + help_text: str, + fixed_args: List[str], + base_args: List[str], + output_root: Path, + timeout: int, + simulator_no_progress_timeout: int, + metric: str, + config: Dict[str, Any], +) -> RunRecord: + log_path = output_root / run_def.name / "run.log" + run_name = f"autofl_{run_def.name}" + simulator_root = Path("/tmp/nvflare/simulation") / run_name + name_args = ["--name", run_name] if supports_flag(help_text, "--name") else [] + command = [python, str(job), *fixed_args, *base_args, *name_args, *run_def.args] + run_def.command = command + remove_known_result_dir(config) + if name_args: + shutil.rmtree(simulator_root, ignore_errors=True) + rc, stdout, runtime = run_allow_timeout( + command, + cwd, + timeout, + log_path, + simulator_stall_roots=[simulator_root], + simulator_no_progress_timeout=simulator_no_progress_timeout, + ) + run_def.runtime_seconds = runtime + result_dir = extract_result_dir(stdout) or (simulator_root if simulator_root.exists() else None) + artifact_dir = collect_artifacts(result_dir, output_root, run_def.name, log_path) + run_def.artifacts = str(artifact_dir) + + if rc != 0: + if is_sandbox_socket_failure(stdout): + run_def.status = INFRASTRUCTURE_RETRY + run_def.failure_reason = "sandbox/socket permission failure; rerun runner with escalated execution" + elif is_nvflare_simulator_stall(stdout): + run_def.status = "crash" + run_def.failure_reason = ( + "nvflare simulator watchdog detected a child connection/no-progress stall; " + "candidate killed and campaign continued" + ) + else: + run_def.status = "crash" + run_def.failure_reason = f"exit_code={rc}" + else: + score = extract_score(artifact_dir, metric) + if score is None: + run_def.status = "crash" + run_def.failure_reason = f"metric '{metric}' not found" + else: + run_def.score = score + + return RunRecord( + status=run_def.status, + name=run_def.name, + score=run_def.score, + runtime_seconds=run_def.runtime_seconds, + changed_files="none", + diff_summary=run_def.description, + run_command=shlex.join(command), + artifacts=run_def.artifacts, + failure_reason=run_def.failure_reason, + ) + + +def write_results(path: Path, records: List[RunRecord]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=RESULT_FIELDS, delimiter="\t") + writer.writeheader() + for record in records: + writer.writerow( + { + "status": record.status, + "name": record.name, + "score": "" if record.score is None else f"{record.score:.6f}", + "runtime_seconds": f"{record.runtime_seconds:.3f}", + "changed_files": record.changed_files, + "diff_summary": record.diff_summary, + "run_command": record.run_command, + "artifacts": record.artifacts, + "failure_reason": record.failure_reason, + } + ) + + +def better(new_score: Optional[float], old_score: Optional[float], mode: str) -> bool: + if new_score is None: + return False + if old_score is None: + return True + return new_score > old_score if mode == "max" else new_score < old_score + + +def finalize_candidates(records: List[RunRecord], mode: str) -> None: + baseline = next((r for r in records if r.status == "baseline"), None) + best_score = baseline.score if baseline else None + for record in records: + if record.status == "keep" and better(record.score, best_score, mode): + best_score = record.score + best_idx: Optional[int] = None + for idx, record in enumerate(records): + if record.status != "candidate": + continue + if better(record.score, best_score, mode): + best_score = record.score + best_idx = idx + for idx, record in enumerate(records): + if record.status != "candidate": + continue + record.status = "keep" if idx == best_idx else "discard" + + +def write_state( + path: Path, + records: List[RunRecord], + max_candidates: Optional[int], + *, + manual_stop: bool = False, +) -> None: + attempts = len([r for r in records if r.status in {"keep", "discard", "crash"}]) + if manual_stop: + state = { + "schema_version": "nvflare.autofl.skill_state.v1", + "candidate_cap": max_candidates, + "candidate_attempts": attempts, + "decision": "manual_stop", + "next_action": "final_report", + "final_response_allowed": True, + } + write_json(path, state) + return + + if any(r.status == INFRASTRUCTURE_RETRY for r in records): + state = { + "schema_version": "nvflare.autofl.skill_state.v1", + "candidate_cap": max_candidates, + "candidate_attempts": attempts, + "decision": "retry_infrastructure", + "next_action": "rerun_with_escalated_execution", + "final_response_allowed": False, + } + write_json(path, state) + return + + if max_candidates is None: + state = { + "schema_version": "nvflare.autofl.skill_state.v1", + "candidate_cap": None, + "candidate_attempts": attempts, + "decision": "continue", + "next_action": "launch_next_candidate", + "final_response_allowed": False, + } + write_json(path, state) + return + + state = { + "schema_version": "nvflare.autofl.skill_state.v1", + "candidate_cap": max_candidates, + "candidate_attempts": attempts, + "decision": "stop" if attempts >= max_candidates else "continue", + "next_action": "final_report" if attempts >= max_candidates else "launch_next_candidate", + "final_response_allowed": attempts >= max_candidates, + } + write_json(path, state) + + +def write_progress(path: Path, records: List[RunRecord], mode: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + try: + from PIL import Image, ImageDraw, ImageFont + except ImportError: + path.write_text("Pillow is not installed; progress image unavailable.\n", encoding="utf-8") + return + + width, height = 1000, 560 + margin = 70 + scores = [r.score for r in records if r.score is not None] + image = Image.new("RGB", (width, height), "white") + draw = ImageDraw.Draw(image) + font = ImageFont.load_default() + draw.text((margin, 24), f"Auto-FL Progress: {len(records)} rows, {len(scores)} scored", fill="black", font=font) + draw.line((margin, height - margin, width - margin, height - margin), fill=(80, 80, 80), width=2) + draw.line((margin, margin, margin, height - margin), fill=(80, 80, 80), width=2) + + if scores: + lo, hi = min(scores), max(scores) + if lo == hi: + lo -= 1.0 + hi += 1.0 + span = hi - lo + running_best: Optional[float] = None + last_point: Optional[Tuple[float, float]] = None + denom = max(1, len(records) - 1) + for idx, record in enumerate(records): + if record.score is None: + continue + x = margin + (width - 2 * margin) * idx / denom + y = height - margin - (height - 2 * margin) * (record.score - lo) / span + color = (40, 160, 90) if record.status in {"baseline", "keep"} else (150, 150, 150) + draw.ellipse((x - 5, y - 5, x + 5, y + 5), fill=color, outline="black") + draw.text((x + 6, y - 14), f"{record.name}: {record.score:.3f}", fill=color, font=font) + if better(record.score, running_best, mode): + running_best = record.score + if running_best == record.score: + if last_point: + draw.line((last_point[0], last_point[1], x, y), fill=(40, 160, 90), width=2) + last_point = (x, y) + image.save(path) + + +def write_report(path: Path, config: Dict[str, Any], records: List[RunRecord], args: argparse.Namespace) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + best = None + for record in records: + if record.status in {"baseline", "keep"} and better(record.score, best.score if best else None, args.mode): + best = record + candidate_budget = ( + str(args.max_candidates) if args.max_candidates is not None else "uncapped; runs until manual interruption" + ) + lines = [ + "# Auto-FL Report", + "", + f"Objective: optimize `{args.metric}` in `{args.target_env}`.", + f"Candidate budget: `{candidate_budget}`.", + f"Config: `{args.autofl_yaml}`.", + f"Fixed budget: `{json.dumps(config.get('budget', {}).get('fixed_training_budget', {}), sort_keys=True)}`.", + "", + "## Leaderboard", + "", + "| Status | Name | Score | Artifacts | Notes |", + "| --- | --- | ---: | --- | --- |", + ] + for record in records: + score = "" if record.score is None else f"{record.score:.6f}" + lines.append(f"| {record.status} | {record.name} | {score} | `{record.artifacts}` | {record.diff_summary} |") + lines.extend( + [ + "", + "## Artifacts", + "", + f"- Ledger: `{args.results}`", + f"- Progress plot: `{args.progress}`", + f"- Campaign state: `{args.state}`", + "", + "## Outcome", + "", + ] + ) + if best: + lines.append(f"Best retained run: `{best.name}` with `{args.metric}={best.score:.6f}`.") + else: + lines.append("No scored run was retained.") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + if args.max_candidates is not None and args.max_candidates < 1: + print("--max-candidates must be positive when provided", file=sys.stderr) + return 2 + job = Path(args.job).resolve() + cwd = job.parent + autofl_yaml = resolve_output_path(cwd, args.autofl_yaml) + results = resolve_output_path(cwd, args.results) + state = resolve_output_path(cwd, args.state) + progress = resolve_output_path(cwd, args.progress) + report = resolve_output_path(cwd, args.report) + output_root = resolve_output_path(cwd, args.output_root) + output_root.mkdir(parents=True, exist_ok=True) + schema = load_mutation_schema(cwd) + profile_budget = h100_comparison_budget(schema) + profile_timeout = profile_budget.get("run_timeout_seconds") + timeout = max(args.timeout, int(profile_timeout)) if profile_timeout is not None else args.timeout + profile_no_progress_timeout = profile_budget.get("simulator_no_progress_timeout_seconds") + simulator_no_progress_timeout = ( + int(profile_no_progress_timeout) + if profile_no_progress_timeout is not None + else args.simulator_no_progress_timeout + ) + + import_cmd = [ + args.python, + "-m", + "nvflare.app_common.autofl.job_importer", + str(job), + "--metric", + args.metric, + "--env", + args.target_env, + "--output", + str(autofl_yaml), + ] + if args.max_candidates is not None: + import_cmd.extend(["--max-candidates", str(args.max_candidates)]) + rc, output, _ = run(import_cmd, cwd, timeout, output_root / "import.log") + if rc != 0: + print(output, file=sys.stderr) + return rc + + config = read_yaml(autofl_yaml) + help_text = job_help(args.python, job, cwd) + fixed_args = build_fixed_args(config, help_text, schema) + base_args = build_base_args(args, help_text, schema) + + records: List[RunRecord] = [] + baseline = JobRun(name="baseline", args=[], description="baseline", status="baseline") + baseline_record = run_job( + baseline, + python=args.python, + job=job, + cwd=cwd, + help_text=help_text, + fixed_args=fixed_args, + base_args=base_args, + output_root=output_root, + timeout=timeout, + simulator_no_progress_timeout=simulator_no_progress_timeout, + metric=args.metric, + config=config, + ) + records.append(baseline_record) + write_results(results, records) + write_state(state, records, args.max_candidates) + write_progress(progress, records, args.mode) + write_report(report, config, records, args) + if baseline_record.status == INFRASTRUCTURE_RETRY: + write_report(report, config, records, args) + print( + json.dumps( + { + "autofl_yaml": str(autofl_yaml.resolve()), + "results": str(results.resolve()), + "state": str(state.resolve()), + "progress": str(progress.resolve()), + "report": str(report.resolve()), + "candidate_attempts": 0, + "next_action": "rerun_with_escalated_execution", + }, + indent=2, + sort_keys=True, + ) + ) + return 75 + if baseline_record.score is None: + write_report(report, config, records, args) + print(f"Baseline run did not produce metric '{args.metric}'. See {baseline_record.artifacts}", file=sys.stderr) + return 1 + + try: + for candidate in candidate_plan(config, help_text, args.max_candidates, schema): + record = run_job( + candidate, + python=args.python, + job=job, + cwd=cwd, + help_text=help_text, + fixed_args=fixed_args, + base_args=base_args, + output_root=output_root, + timeout=timeout, + simulator_no_progress_timeout=simulator_no_progress_timeout, + metric=args.metric, + config=config, + ) + records.append(record) + finalize_candidates(records, args.mode) + write_results(results, records) + write_state(state, records, args.max_candidates) + write_progress(progress, records, args.mode) + write_report(report, config, records, args) + if record.status == INFRASTRUCTURE_RETRY: + print( + json.dumps( + { + "autofl_yaml": str(autofl_yaml.resolve()), + "results": str(results.resolve()), + "state": str(state.resolve()), + "progress": str(progress.resolve()), + "report": str(report.resolve()), + "candidate_attempts": len([r for r in records if r.status in {"keep", "discard", "crash"}]), + "next_action": "rerun_with_escalated_execution", + }, + indent=2, + sort_keys=True, + ) + ) + return 75 + except KeyboardInterrupt: + write_results(results, records) + write_state(state, records, args.max_candidates, manual_stop=True) + write_progress(progress, records, args.mode) + write_report(report, config, records, args) + print( + json.dumps( + { + "autofl_yaml": str(autofl_yaml.resolve()), + "results": str(results.resolve()), + "state": str(state.resolve()), + "progress": str(progress.resolve()), + "report": str(report.resolve()), + "candidate_attempts": len([r for r in records if r.status in {"keep", "discard", "crash"}]), + "next_action": "final_report", + }, + indent=2, + sort_keys=True, + ) + ) + return 130 + + write_report(report, config, records, args) + candidate_attempts = len([r for r in records if r.status in {"keep", "discard", "crash"}]) + print( + json.dumps( + { + "autofl_yaml": str(autofl_yaml.resolve()), + "results": str(results.resolve()), + "state": str(state.resolve()), + "progress": str(progress.resolve()), + "report": str(report.resolve()), + "candidate_attempts": candidate_attempts, + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit_test/research/autofl_campaign_guard_test.py b/tests/unit_test/research/autofl_campaign_guard_test.py new file mode 100644 index 0000000000..349cb3fe5f --- /dev/null +++ b/tests/unit_test/research/autofl_campaign_guard_test.py @@ -0,0 +1,95 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +GUARD = REPO_ROOT / "research" / "auto-fl-research" / "scripts" / "campaign_guard.py" +HEADER = "commit\tscore\truntime_seconds\tbudget\tstatus\ttarget\tdescription\tartifacts\n" + + +def _write_results(path, rows): + path.write_text(HEADER + "\n".join(rows) + "\n", encoding="utf-8") + + +def _run_guard(tmp_path, *args): + state_path = tmp_path / "campaign_state.json" + command = [ + sys.executable, + str(GUARD), + str(tmp_path / "results.tsv"), + "--state", + str(state_path), + "--format", + "json", + *args, + ] + process = subprocess.run(command, cwd=tmp_path, text=True, capture_output=True, check=True) + payload = json.loads(process.stdout) + assert json.loads(state_path.read_text(encoding="utf-8")) == payload + return payload + + +def test_campaign_guard_continues_uncapped_after_verified_default(tmp_path): + _write_results( + tmp_path / "results.tsv", + [ + "abc\t0.84\t10\t--name baseline\tkeep\tjob.py\tbaseline\t/tmp/baseline", + "def\t0.87\t20\t--name encoded\tkeep\tjob.py\tencoded defaults verified\t/tmp/encoded", + ], + ) + + payload = _run_guard(tmp_path) + + assert payload["decision"] == "continue" + assert payload["next_action"] == "launch_next_candidate_batch" + assert payload["final_response_allowed"] is False + assert "Do not produce a final answer" in payload["agent_instruction"] + + +def test_campaign_guard_requires_finalizing_pending_candidates(tmp_path): + _write_results( + tmp_path / "results.tsv", + [ + "abc\t0.84\t10\t--name baseline\tkeep\tjob.py\tbaseline\t/tmp/baseline", + "def\t0.86\t20\t--name candidate\tcandidate\tjob.py\tcandidate row\t/tmp/candidate", + ], + ) + + payload = _run_guard(tmp_path) + + assert payload["decision"] == "continue" + assert payload["reason"] == "pending_candidates" + assert payload["next_action"] == "finalize_pending_candidates" + assert payload["final_response_allowed"] is False + + +def test_campaign_guard_allows_final_report_after_explicit_cap(tmp_path): + _write_results( + tmp_path / "results.tsv", + [ + "abc\t0.84\t10\t--name baseline\tkeep\tjob.py\tbaseline\t/tmp/baseline", + "def\t0.86\t20\t--name candidate\tdiscard\tjob.py\tcandidate row\t/tmp/candidate", + ], + ) + + payload = _run_guard(tmp_path, "--max-candidates", "1") + + assert payload["decision"] == "stop" + assert payload["reason"] == "candidate_cap_exhausted" + assert payload["next_action"] == "final_report" + assert payload["final_response_allowed"] is True diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py new file mode 100644 index 0000000000..f629931bf4 --- /dev/null +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -0,0 +1,195 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + + +def _load_runner(): + repo_root = Path(__file__).parents[3] + runner_path = repo_root / "skills" / "nvflare-autofl" / "scripts" / "run_job_campaign.py" + spec = importlib.util.spec_from_file_location("nvflare_autofl_skill_runner", runner_path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_candidate_args_respect_mutation_schema_bounds(): + runner = _load_runner() + schema = {"mutable_args": {"lr": {"type": "float", "min": 0.0001, "max": 0.1}}} + + assert runner.candidate_args_allowed(["--lr", "0.1"], schema) == (True, "") + + allowed, reason = runner.candidate_args_allowed(["--lr", "0.2"], schema) + assert not allowed + assert "above schema max" in reason + + +def test_candidate_plan_skips_out_of_bounds_learning_rate(): + runner = _load_runner() + config = {"search_space": {"suggested": {"lr": {"default": 0.05}}}} + schema = {"mutable_args": {"lr": {"type": "float", "min": 0.0001, "max": 0.1}}} + help_text = "--lr" + + candidates = list(runner.candidate_plan(config, help_text, max_candidates=10, schema=schema)) + candidate_commands = [" ".join(candidate.args) for candidate in candidates] + + assert "--lr 0.1" in candidate_commands + assert "--lr 0.2" not in candidate_commands + + +def test_profile_budget_suppresses_duplicate_imported_fixed_budget_args(): + runner = _load_runner() + config = {"budget": {"fixed_training_budget": {"num_clients": 8, "num_rounds": 10}}} + schema = { + "comparison_budget_args": { + "h100_default_candidate_budget": { + "n_clients": 8, + "num_rounds": 20, + } + } + } + help_text = "--n_clients --num_rounds" + + assert runner.build_fixed_args(config, help_text, schema) == [] + + args = SimpleNamespace(base_args="", prefer_synthetic=False, synthetic_train_size=1, synthetic_test_size=1) + assert runner.build_base_args(args, help_text, schema) == ["--n_clients", "8", "--num_rounds", "20"] + + +def test_run_streams_output_before_timeout(tmp_path): + runner = _load_runner() + log_path = tmp_path / "run.log" + + rc, output, _ = runner.run( + [ + sys.executable, + "-c", + "import time; print('started', flush=True); time.sleep(2); print('finished', flush=True)", + ], + tmp_path, + timeout=1, + log_path=log_path, + ) + + log_text = log_path.read_text(encoding="utf-8") + assert rc == 124 + assert "started" in output + assert "started" in log_text + assert "TIMEOUT after 1s" in log_text + + +def test_run_stops_on_nvflare_simulator_stall_log(tmp_path): + runner = _load_runner() + log_path = tmp_path / "run.log" + sim_root = tmp_path / "simulation" / "autofl_candidate" + server_log = sim_root / "server" / "log_fl.txt" + server_log.parent.mkdir(parents=True) + server_log.write_text( + "SimulatorClientRunner - ERROR - run_client_thread error: RuntimeError: " + "Failed to create connection to the child process in SimulatorClientRunner, timeout: 60.0\n", + encoding="utf-8", + ) + + rc, output, runtime = runner.run( + [ + sys.executable, + "-c", + "import time; print('started', flush=True); time.sleep(30)", + ], + tmp_path, + timeout=30, + log_path=log_path, + simulator_stall_roots=[sim_root], + stall_check_interval=0.01, + ) + + log_text = log_path.read_text(encoding="utf-8") + assert rc == runner.SIMULATOR_STALL_EXIT_CODE + assert runtime < 5 + assert "SIMULATOR_STALL:" in output + assert "SIMULATOR_STALL:" in log_text + + +def test_run_stops_on_nvflare_simulator_no_progress_log(tmp_path): + runner = _load_runner() + log_path = tmp_path / "run.log" + sim_root = tmp_path / "simulation" / "autofl_candidate" + server_log = sim_root / "server" / "log_fl.txt" + server_log.parent.mkdir(parents=True) + server_log.write_text("Round 0 started\n", encoding="utf-8") + + rc, output, runtime = runner.run( + [ + sys.executable, + "-c", + "import time; print('started', flush=True); time.sleep(30)", + ], + tmp_path, + timeout=30, + log_path=log_path, + simulator_stall_roots=[sim_root], + stall_check_interval=0.01, + simulator_no_progress_timeout=1, + ) + + log_text = log_path.read_text(encoding="utf-8") + assert rc == runner.SIMULATOR_STALL_EXIT_CODE + assert runtime < 5 + assert "SIMULATOR_STALL: no simulator progress markers changed" in output + assert "SIMULATOR_STALL: no simulator progress markers changed" in log_text + + +def test_run_stops_on_stale_partial_simulator_aggregation(tmp_path): + runner = _load_runner() + log_path = tmp_path / "run.log" + sim_root = tmp_path / "simulation" / "autofl_candidate" + server_log = sim_root / "server" / "log_fl.txt" + site_log = sim_root / "site-1" / "log_fl.txt" + server_log.parent.mkdir(parents=True) + site_log.parent.mkdir(parents=True) + server_log.write_text( + "Round 0 started\n" "2026-06-25 06:32:33 - FedAvg - INFO - Aggregated 1/8 results\n", + encoding="utf-8", + ) + site_log.write_text("[site=site-1] round=0\n", encoding="utf-8") + + rc, output, runtime = runner.run( + [ + sys.executable, + "-c", + ( + "import pathlib, time; " + f"path = pathlib.Path({str(site_log)!r}); " + "time.sleep(0.2); " + "path.write_text('[site=site-1] round=0\\n[site=site-2] round=0\\n'); " + "time.sleep(30)" + ), + ], + tmp_path, + timeout=30, + log_path=log_path, + simulator_stall_roots=[sim_root], + stall_check_interval=0.01, + simulator_no_progress_timeout=1, + ) + + log_text = log_path.read_text(encoding="utf-8") + assert rc == runner.SIMULATOR_STALL_EXIT_CODE + assert runtime < 5 + assert "SIMULATOR_STALL: partial simulator aggregation made no server-side progress" in output + assert "Aggregated 1/8 results" in log_text From cb2bf31b69b9834987b907a3ed2eb7ec129c15c5 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Thu, 25 Jun 2026 11:07:21 -0400 Subject: [PATCH 07/23] Fix Auto-FL skill release packaging references --- skills/nvflare-autofl/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/nvflare-autofl/SKILL.md b/skills/nvflare-autofl/SKILL.md index ab3db5b718..c0d5f71a10 100644 --- a/skills/nvflare-autofl/SKILL.md +++ b/skills/nvflare-autofl/SKILL.md @@ -51,7 +51,7 @@ python "$CODEX_HOME/skills/nvflare-autofl/scripts/run_job_campaign.py" ./job.py If the user did not specify a candidate cap, omit `--max-candidates`; the runner will keep launching same-budget candidate attempts and refresh `results.tsv`, `progress.png`, `.nvflare/autofl/campaign_state.json`, and -`autofl_report.md` after every finalized candidate until interrupted or blocked. +the Auto-FL report after every finalized candidate until interrupted or blocked. In this uncapped mode, do not ask the user whether to keep going. Report checkpoint status as an observation, then continue monitoring or executing the same runner while `final_response_allowed=false`. @@ -61,8 +61,8 @@ comparison budget and mutation bounds as authoritative. Invalid generated proposals are product friction, not campaign blockers; preserve the same campaign and continue with another same-budget candidate. -Do not read or follow research harness prose, `program.md`, task profile -runbooks, `scripts/init_run.sh`, `.autoresearch` branch rules, or manual +Do not read or follow research harness program prose, task profile runbooks, +`scripts/init_run.sh`, `.autoresearch` branch rules, or manual research campaign instructions before starting the product runner. Those files belong to the incubator workflow and can pull the agent back into an old agent-driven loop. The product runner may read `mutation_schema.yaml`, From 46c4d5c4a1921937482a632890c0271496bdc782 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Thu, 25 Jun 2026 12:12:09 -0400 Subject: [PATCH 08/23] Address Auto-FL Greptile review findings --- nvflare/app_common/autofl/job_importer.py | 4 +- .../scripts/campaign_guard.py | 14 ++++- .../scripts/run_job_campaign.py | 1 - .../app_common/autofl/job_importer_test.py | 54 +++++++++++++++++++ .../research/autofl_campaign_guard_test.py | 23 +++++++- 5 files changed, 89 insertions(+), 7 deletions(-) diff --git a/nvflare/app_common/autofl/job_importer.py b/nvflare/app_common/autofl/job_importer.py index 0ea15cfef6..dc93e5beaf 100644 --- a/nvflare/app_common/autofl/job_importer.py +++ b/nvflare/app_common/autofl/job_importer.py @@ -410,7 +410,7 @@ def _resolved_call_keywords( "source": value.source, "confidence": value.confidence, } - if value.unresolved: + if value.unresolved and key != "model": unresolved.append(_unresolved(f"job.{call_info.name}.{key}", value.source)) return resolved, unresolved @@ -629,7 +629,7 @@ def _resolve_value( arg_value = _first_resolved_argparse_string(node, assignments, parser_args, source_text) if arg_value is not None: return arg_value - return ResolvedValue(_source_segment(source_text, node) or call_name, f"call:{call_name}", "medium") + return ResolvedValue(_source_segment(source_text, node) or call_name, f"call:{call_name}", "low", True) return ResolvedValue(_source_segment(source_text, node) or type(node).__name__, "expression", "low", True) diff --git a/research/auto-fl-research/scripts/campaign_guard.py b/research/auto-fl-research/scripts/campaign_guard.py index 896fb461fd..0dcf42279d 100755 --- a/research/auto-fl-research/scripts/campaign_guard.py +++ b/research/auto-fl-research/scripts/campaign_guard.py @@ -99,12 +99,22 @@ def best_score(rows: list[dict[str, str]]) -> float | None: def parse_max_candidates(value: str | None) -> int | None: if value is None or str(value).strip() == "": return None - parsed = int(value) + try: + parsed = int(value) + except (TypeError, ValueError): + return None if parsed <= 0: return None return parsed +def parse_max_candidates_arg(value: str) -> int: + parsed = parse_max_candidates(value) + if parsed is None: + raise argparse.ArgumentTypeError("must be a positive integer") + return parsed + + def existing_stop_files(paths: list[str]) -> list[str]: return [path for path in paths if Path(path).exists()] @@ -253,7 +263,7 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("results", nargs="?", default="results.tsv") parser.add_argument("--state", default=DEFAULT_STATE_PATH) - parser.add_argument("--max-candidates", type=parse_max_candidates) + parser.add_argument("--max-candidates", type=parse_max_candidates_arg) parser.add_argument("--stop-file", action="append", default=list(DEFAULT_STOP_FILES)) parser.add_argument("--plateau-threshold", type=int, default=DEFAULT_MAX_SCORED_SINCE_RESET) parser.add_argument("--min-delta", type=float, default=DEFAULT_MIN_DELTA) diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index 5f2fb9f95f..6768aaafec 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -1222,7 +1222,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int: write_progress(progress, records, args.mode) write_report(report, config, records, args) if baseline_record.status == INFRASTRUCTURE_RETRY: - write_report(report, config, records, args) print( json.dumps( { diff --git a/tests/unit_test/app_common/autofl/job_importer_test.py b/tests/unit_test/app_common/autofl/job_importer_test.py index 7033db9914..67fdf1fdc0 100644 --- a/tests/unit_test/app_common/autofl/job_importer_test.py +++ b/tests/unit_test/app_common/autofl/job_importer_test.py @@ -273,6 +273,60 @@ def main(): assert {"field": "job.FedAvgRecipe.num_rounds", "reason": "name:NUM_ROUNDS"} in config["unresolved"] +def test_import_marks_call_expression_budget_and_metric_unresolved(tmp_path): + (tmp_path / "client.py").write_text( + """ +def train(): + pass +""", + encoding="utf-8", + ) + job_path = tmp_path / "job.py" + job_path.write_text( + """ +from nvflare.app_opt.pt.recipes.fedavg import FedAvgRecipe +from nvflare.recipe import SimEnv + + +def get_metric(): + return "accuracy" + + +def get_rounds(): + return 5 + + +def main(): + recipe = FedAvgRecipe( + name="demo", + min_clients=2, + num_rounds=get_rounds(), + train_script="client.py", + key_metric=get_metric(), + ) + recipe.execute(SimEnv(num_clients=2)) +""", + encoding="utf-8", + ) + + config = import_job_to_autofl_config(str(job_path), workspace_root=str(tmp_path)) + + assert config["objective"] == {"metric": "accuracy", "mode": "max", "source": "default"} + assert config["budget"]["fixed_training_budget"] == {"min_clients": 2, "num_clients": 2} + assert { + "field": "budget.fixed_training_budget.num_rounds", + "reason": "call:get_rounds", + } in config["unresolved"] + assert {"field": "objective.metric", "reason": "call:get_metric"} in config["unresolved"] + assert {"field": "job.FedAvgRecipe.key_metric", "reason": "call:get_metric"} in config["unresolved"] + assert {"field": "job.FedAvgRecipe.num_rounds", "reason": "call:get_rounds"} in config["unresolved"] + assert config["job"]["recipe_args"]["num_rounds"] == { + "value": "get_rounds()", + "source": "call:get_rounds", + "confidence": "low", + } + + def test_import_marks_unsupported_custom_job_as_partial(tmp_path): job_path = tmp_path / "job.py" job_path.write_text( diff --git a/tests/unit_test/research/autofl_campaign_guard_test.py b/tests/unit_test/research/autofl_campaign_guard_test.py index 349cb3fe5f..4d0ce71a0d 100644 --- a/tests/unit_test/research/autofl_campaign_guard_test.py +++ b/tests/unit_test/research/autofl_campaign_guard_test.py @@ -13,6 +13,7 @@ # limitations under the License. import json +import os import subprocess import sys from pathlib import Path @@ -26,7 +27,7 @@ def _write_results(path, rows): path.write_text(HEADER + "\n".join(rows) + "\n", encoding="utf-8") -def _run_guard(tmp_path, *args): +def _run_guard(tmp_path, *args, env=None): state_path = tmp_path / "campaign_state.json" command = [ sys.executable, @@ -38,7 +39,8 @@ def _run_guard(tmp_path, *args): "json", *args, ] - process = subprocess.run(command, cwd=tmp_path, text=True, capture_output=True, check=True) + process_env = None if env is None else {**os.environ, **env} + process = subprocess.run(command, cwd=tmp_path, text=True, capture_output=True, check=True, env=process_env) payload = json.loads(process.stdout) assert json.loads(state_path.read_text(encoding="utf-8")) == payload return payload @@ -93,3 +95,20 @@ def test_campaign_guard_allows_final_report_after_explicit_cap(tmp_path): assert payload["reason"] == "candidate_cap_exhausted" assert payload["next_action"] == "final_report" assert payload["final_response_allowed"] is True + + +def test_campaign_guard_ignores_malformed_env_cap(tmp_path): + _write_results( + tmp_path / "results.tsv", + [ + "abc\t0.84\t10\t--name baseline\tkeep\tjob.py\tbaseline\t/tmp/baseline", + "def\t0.86\t20\t--name candidate\tdiscard\tjob.py\tcandidate row\t/tmp/candidate", + ], + ) + + payload = _run_guard(tmp_path, env={"AUTOFL_MAX_CANDIDATES": "not-a-number"}) + + assert payload["decision"] == "continue" + assert payload["reason"] == "continue" + assert payload["candidate_cap"] is None + assert payload["final_response_allowed"] is False From 2e287b4507a9be9b9a1bae01bce5f5eb4304215a Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Thu, 25 Jun 2026 14:35:47 -0400 Subject: [PATCH 09/23] Add Auto-FL skill campaign guard --- .../references/continuous-campaigns.md | 19 +- .../nvflare-autofl/scripts/campaign_guard.py | 369 ++++++++++++++++++ .../scripts/run_job_campaign.py | 314 +++++++++++---- .../tool/autofl_skill_campaign_guard_test.py | 163 ++++++++ .../tool/autofl_skill_runner_test.py | 71 ++++ 5 files changed, 846 insertions(+), 90 deletions(-) create mode 100644 skills/nvflare-autofl/scripts/campaign_guard.py create mode 100644 tests/unit_test/tool/autofl_skill_campaign_guard_test.py diff --git a/skills/nvflare-autofl/references/continuous-campaigns.md b/skills/nvflare-autofl/references/continuous-campaigns.md index d0423a9275..fdcc6107e1 100644 --- a/skills/nvflare-autofl/references/continuous-campaigns.md +++ b/skills/nvflare-autofl/references/continuous-campaigns.md @@ -27,19 +27,22 @@ GPU use and the child process remain active, keep waiting. ## Campaign Guards -If `.autoresearch/campaign_state.json` exists, read it before any final -response. If `scripts/campaign_guard.py` exists, run it after every checkpoint -and before any final response. If the state has `final_response_allowed=false`, -execute `next_action` immediately; the skill text is only the interaction layer. +The product runner writes `.nvflare/autofl/campaign_state.json` through +`scripts/campaign_guard.py`; read that state before any final response. If a +legacy `.autoresearch/campaign_state.json` also exists, treat it as additional +research-harness context, but do not let it override the product runner state. +If the state has `final_response_allowed=false`, execute `next_action` +immediately; the skill text is only the interaction layer. Common next actions: - `finalize_pending_candidates`: finalize reviewed candidate rows and rerun the guard. -- `run_literature_loop`: run the literature loop and launch source-backed - candidates. -- `launch_next_candidate_batch`: choose a safe same-budget axis and launch the - next batch. +- `run_literature_loop`: run a short source-backed literature pass, record a + non-scored `literature` row when a ledger is available, then launch the next + compatible same-budget candidates. +- `launch_next_candidate` or `launch_next_candidate_batch`: choose a safe + same-budget axis and launch the next candidate or batch. After every finalized batch, run the available plateau or progress watchdog when the task provides one. If it recommends `continue`, refresh `progress.png` and diff --git a/skills/nvflare-autofl/scripts/campaign_guard.py b/skills/nvflare-autofl/scripts/campaign_guard.py new file mode 100644 index 0000000000..cc10650898 --- /dev/null +++ b/skills/nvflare-autofl/scripts/campaign_guard.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Own the product Auto-FL campaign continuation decision. + +The skill runner executes candidates and writes ``results.tsv``. This guard +turns that ledger into a machine-readable campaign state so a live coding agent +does not decide completion, plateau handling, or literature-mode transitions by +itself. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +DEFAULT_HARD_CRASH_THRESHOLD = 6 +DEFAULT_MIN_DELTA = 0.0005 +DEFAULT_PLATEAU_THRESHOLD = 32 +DEFAULT_STATE_PATH = ".nvflare/autofl/campaign_state.json" +DEFAULT_STOP_FILES = ("STOP_AUTOFL", ".nvflare/autofl/STOP") +COMPARABLE_STATUSES = {"candidate", "keep", "discard", "crash"} +LITERATURE_EVENT_STATUSES = {"event", "literature", "checkpoint"} + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def parse_score(value: Any) -> Optional[float]: + try: + score = float(value) + except (TypeError, ValueError): + return None + if math.isnan(score) or math.isinf(score): + return None + return score + + +def load_rows(path: Path) -> List[Dict[str, str]]: + if not path.exists(): + return [] + with path.open("r", encoding="utf-8", newline="") as f: + return list(csv.DictReader(f, delimiter="\t")) + + +def normalize_status(row: Dict[str, str]) -> str: + return (row.get("status", "") or "").strip().lower() + + +def row_text(row: Dict[str, str]) -> str: + return " ".join(str(value or "") for value in row.values()).lower() + + +def is_baseline(row: Dict[str, str]) -> bool: + status = normalize_status(row) + if status == "baseline": + return True + name = (row.get("name", "") or "").strip().lower() + command = (row.get("run_command", "") or "").lower() + return name == "baseline" or name.startswith("baseline_") or "--name baseline" in command + + +def is_literature_event(row: Dict[str, str]) -> bool: + status = normalize_status(row) + if status in LITERATURE_EVENT_STATUSES and "literature" in row_text(row): + return True + return "literature" in (row.get("name", "") or "").lower() + + +def comparable_attempts(rows: List[Dict[str, str]]) -> List[Dict[str, str]]: + return [row for row in rows if normalize_status(row) in COMPARABLE_STATUSES and not is_baseline(row)] + + +def pending_candidates(rows: List[Dict[str, str]]) -> List[Dict[str, str]]: + return [row for row in rows if normalize_status(row) == "candidate"] + + +def scored_attempts_with_index(rows: List[Dict[str, str]]) -> List[Tuple[int, Dict[str, str], float]]: + scored = [] + for idx, row in enumerate(rows): + if normalize_status(row) not in COMPARABLE_STATUSES or is_baseline(row): + continue + score = parse_score(row.get("score", "")) + if score is not None: + scored.append((idx, row, score)) + return scored + + +def better(new_score: float, old_score: Optional[float], mode: str, min_delta: float = 0.0) -> bool: + if old_score is None: + return True + if mode == "min": + return new_score < old_score - min_delta + return new_score > old_score + min_delta + + +def best_score(rows: List[Dict[str, str]], mode: str) -> Optional[float]: + best = None + for row in rows: + if normalize_status(row) not in COMPARABLE_STATUSES and not is_baseline(row): + continue + score = parse_score(row.get("score", "")) + if score is None: + continue + if better(score, best, mode): + best = score + return best + + +def plateau_status(rows: List[Dict[str, str]], threshold: int, min_delta: float, mode: str) -> Dict[str, Any]: + scored = [] + for idx, row in enumerate(rows): + if normalize_status(row) not in COMPARABLE_STATUSES and not is_baseline(row): + continue + score = parse_score(row.get("score", "")) + if score is not None: + scored.append((idx, row, score)) + if threshold <= 0 or not scored: + return { + "available": True, + "recommendation": "continue", + "scored_since_reset": 0, + "threshold": threshold, + "min_delta": min_delta, + } + + best = None + best_row_idx = -1 + best_scored_idx = -1 + best_name = "" + for scored_idx, (row_idx, row, score) in enumerate(scored): + if better(score, best, mode, min_delta): + best = score + best_row_idx = row_idx + best_scored_idx = scored_idx + best_name = row.get("name", "") + + last_literature_idx = max((idx for idx, row in enumerate(rows) if is_literature_event(row)), default=-1) + reset_row_idx = max(best_row_idx, last_literature_idx) + scored_since_reset = sum(1 for row_idx, _, _ in scored if row_idx > reset_row_idx) + recommendation = "literature" if scored_since_reset >= threshold else "continue" + return { + "available": True, + "recommendation": recommendation, + "best_name": best_name, + "best_score": best, + "best_scored_index": best_scored_idx, + "last_literature_event_index": last_literature_idx, + "min_delta": min_delta, + "reset_row_index": reset_row_idx, + "scored_since_reset": scored_since_reset, + "threshold": threshold, + } + + +def parse_max_candidates(value: Optional[str]) -> Optional[int]: + if value is None or str(value).strip() == "": + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + if parsed <= 0: + return None + return parsed + + +def parse_max_candidates_arg(value: str) -> int: + parsed = parse_max_candidates(value) + if parsed is None: + raise argparse.ArgumentTypeError("must be a positive integer") + return parsed + + +def existing_stop_files(paths: List[str]) -> List[str]: + return [path for path in paths if Path(path).exists()] + + +def repeated_crash_blocker(attempts: List[Dict[str, str]], threshold: int) -> bool: + if threshold <= 0 or len(attempts) < threshold: + return False + return all(normalize_status(row) == "crash" for row in attempts[-threshold:]) + + +def guard_state_for_rows( + rows: List[Dict[str, str]], + *, + results_path: str = "results.tsv", + max_candidates: Optional[int] = None, + stop_files: Optional[List[str]] = None, + plateau_threshold: int = DEFAULT_PLATEAU_THRESHOLD, + min_delta: float = DEFAULT_MIN_DELTA, + hard_crash_threshold: int = DEFAULT_HARD_CRASH_THRESHOLD, + mode: str = "max", +) -> Dict[str, Any]: + attempts = comparable_attempts(rows) + pending = pending_candidates(rows) + cap = max_candidates + if cap is None: + cap = parse_max_candidates(os.environ.get("AUTOFL_MAX_CANDIDATES")) + stop_file_hits = existing_stop_files(stop_files or list(DEFAULT_STOP_FILES)) + plateau = plateau_status(rows, plateau_threshold, min_delta, mode) + + decision = "continue" + reason = "continue" + next_action = "launch_next_candidate" + final_response_allowed = False + + if pending: + reason = "pending_candidates" + next_action = "finalize_pending_candidates" + elif stop_file_hits: + decision = "stop" + reason = "manual_stop_file" + next_action = "final_report" + final_response_allowed = True + elif cap is not None and len(attempts) >= cap: + decision = "stop" + reason = "candidate_cap_exhausted" + next_action = "final_report" + final_response_allowed = True + elif repeated_crash_blocker(attempts, hard_crash_threshold): + decision = "stop" + reason = "hard_repeated_crash_blocker" + next_action = "final_report" + final_response_allowed = True + elif plateau.get("recommendation") == "literature": + reason = "plateau_literature" + next_action = "run_literature_loop" + + if final_response_allowed: + instruction = "Final report is allowed because the campaign guard reached a stop condition." + elif next_action == "finalize_pending_candidates": + instruction = ( + "Do not produce a final answer. Finalize reviewed candidate rows, refresh artifacts, then rerun the guard." + ) + elif next_action == "run_literature_loop": + instruction = ( + "Do not produce a final answer. Run the literature loop, record a literature event, " + "then launch source-backed candidates under the same comparison budget." + ) + else: + instruction = "Do not produce a final answer. Launch the next same-budget candidate now." + + return { + "schema_version": "nvflare.autofl.campaign_state.v1", + "updated_at": utc_now(), + "results": results_path, + "decision": decision, + "reason": reason, + "next_action": next_action, + "final_response_allowed": final_response_allowed, + "candidate_cap": cap, + "candidate_attempts": len(attempts), + "pending_candidates": len(pending), + "scored_attempts": len(scored_attempts_with_index(rows)), + "best_score": best_score(rows, mode), + "stop_files": stop_file_hits, + "plateau": plateau, + "agent_instruction": instruction, + } + + +def guard_state( + results_path: Path, + *, + max_candidates: Optional[int] = None, + stop_files: Optional[List[str]] = None, + plateau_threshold: int = DEFAULT_PLATEAU_THRESHOLD, + min_delta: float = DEFAULT_MIN_DELTA, + hard_crash_threshold: int = DEFAULT_HARD_CRASH_THRESHOLD, + mode: str = "max", +) -> Dict[str, Any]: + return guard_state_for_rows( + load_rows(results_path), + results_path=str(results_path), + max_candidates=max_candidates, + stop_files=stop_files, + plateau_threshold=plateau_threshold, + min_delta=min_delta, + hard_crash_threshold=hard_crash_threshold, + mode=mode, + ) + + +def write_state(path: Path, state: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp_path.replace(path) + + +def print_text(state: Dict[str, Any]) -> None: + for key in [ + "decision", + "reason", + "next_action", + "final_response_allowed", + "candidate_cap", + "candidate_attempts", + "pending_candidates", + "scored_attempts", + "best_score", + "agent_instruction", + ]: + value = state.get(key) + if isinstance(value, bool): + value = str(value).lower() + elif value is None: + value = "" + print(f"{key}={value}") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("results", nargs="?", default="results.tsv") + parser.add_argument("--state", default=DEFAULT_STATE_PATH) + parser.add_argument("--max-candidates", type=parse_max_candidates_arg) + parser.add_argument("--stop-file", action="append", default=list(DEFAULT_STOP_FILES)) + parser.add_argument("--plateau-threshold", type=int, default=DEFAULT_PLATEAU_THRESHOLD) + parser.add_argument("--min-delta", type=float, default=DEFAULT_MIN_DELTA) + parser.add_argument("--hard-crash-threshold", type=int, default=DEFAULT_HARD_CRASH_THRESHOLD) + parser.add_argument("--mode", choices=["max", "min"], default="max") + parser.add_argument("--format", choices=["text", "json"], default="text") + args = parser.parse_args(argv) + + if args.plateau_threshold <= 0: + raise ValueError("--plateau-threshold must be positive") + if args.min_delta < 0: + raise ValueError("--min-delta must be non-negative") + + state = guard_state( + Path(args.results), + max_candidates=args.max_candidates, + stop_files=args.stop_file, + plateau_threshold=args.plateau_threshold, + min_delta=args.min_delta, + hard_crash_threshold=args.hard_crash_threshold, + mode=args.mode, + ) + write_state(Path(args.state), state) + if args.format == "json": + print(json.dumps(state, sort_keys=True)) + else: + print_text(state) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index 6768aaafec..3ec02bde4c 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -25,6 +25,7 @@ import argparse import csv +import importlib.util import json import os import re @@ -81,6 +82,9 @@ ) SIMULATOR_PROGRESS_LOG_LIMIT = 131072 DEFAULT_SIMULATOR_NO_PROGRESS_TIMEOUT = 240 +DEFAULT_HARD_CRASH_THRESHOLD = 6 +DEFAULT_PLATEAU_MIN_DELTA = 0.0005 +DEFAULT_PLATEAU_THRESHOLD = 32 FIXED_BUDGET_TO_CLI = { "num_clients": "n_clients", @@ -129,6 +133,22 @@ class JobRun: command: List[str] = field(default_factory=list) +def env_int(name: str, default: int) -> int: + try: + value = int(os.environ.get(name, default)) + except (TypeError, ValueError): + return default + return value + + +def env_float(name: str, default: float) -> float: + try: + value = float(os.environ.get(name, default)) + except (TypeError, ValueError): + return default + return value + + def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("job", help="NVFlare job.py to optimize") @@ -146,14 +166,38 @@ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: parser.add_argument("--progress", default="progress.png") parser.add_argument("--report", default="autofl_report.md") parser.add_argument("--output-root", default="autofl_runs") + parser.add_argument( + "--plateau-threshold", + type=int, + default=env_int("AUTOFL_PLATEAU_THRESHOLD", DEFAULT_PLATEAU_THRESHOLD), + help=( + "scored comparable candidate attempts after the last material improvement or literature event " + "before campaign state requests run_literature_loop" + ), + ) + parser.add_argument( + "--plateau-min-delta", + type=float, + default=env_float("AUTOFL_PLATEAU_MIN_DELTA", DEFAULT_PLATEAU_MIN_DELTA), + help="minimum metric delta required to reset the plateau clock", + ) + parser.add_argument( + "--hard-crash-threshold", + type=int, + default=env_int("AUTOFL_HARD_CRASH_THRESHOLD", DEFAULT_HARD_CRASH_THRESHOLD), + help="stop after this many consecutive real candidate crashes; set 0 to disable", + ) + parser.add_argument( + "--stop-file", + action="append", + help="manual stop-file path; defaults to STOP_AUTOFL and .nvflare/autofl/STOP under the job directory", + ) parser.add_argument("--base-args", default="", help="extra job args applied to baseline and all candidates") parser.add_argument("--timeout", type=int, default=900) parser.add_argument( "--simulator-no-progress-timeout", type=int, - default=int( - os.environ.get("AUTOFL_SIMULATOR_NO_PROGRESS_TIMEOUT_SECONDS", DEFAULT_SIMULATOR_NO_PROGRESS_TIMEOUT) - ), + default=env_int("AUTOFL_SIMULATOR_NO_PROGRESS_TIMEOUT_SECONDS", DEFAULT_SIMULATOR_NO_PROGRESS_TIMEOUT), help=( "candidate-level simulator no-progress timeout in seconds; set 0 to disable. " "This is separate from the full run timeout and only applies after progress markers appear." @@ -440,6 +484,25 @@ def write_json(path: Path, data: Dict[str, Any]) -> None: path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") +_CAMPAIGN_GUARD = None + + +def load_campaign_guard(): + global _CAMPAIGN_GUARD + if _CAMPAIGN_GUARD is not None: + return _CAMPAIGN_GUARD + + guard_path = Path(__file__).resolve().with_name("campaign_guard.py") + spec = importlib.util.spec_from_file_location("nvflare_autofl_skill_campaign_guard", guard_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load campaign guard from {guard_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + _CAMPAIGN_GUARD = module + return module + + def resolve_output_path(cwd: Path, value: str) -> Path: path = Path(value) if path.is_absolute(): @@ -447,6 +510,12 @@ def resolve_output_path(cwd: Path, value: str) -> Path: return cwd / path +def resolve_stop_files(cwd: Path, values: Optional[Sequence[str]]) -> List[str]: + guard = load_campaign_guard() + stop_files = values if values is not None else list(guard.DEFAULT_STOP_FILES) + return [str(resolve_output_path(cwd, value)) for value in stop_files] + + def extract_result_dir(output: str) -> Optional[Path]: patterns = [ r"Result can be found in\s*:\s*(?P\S+)", @@ -1009,57 +1078,79 @@ def finalize_candidates(records: List[RunRecord], mode: str) -> None: def write_state( path: Path, + results_path: Path, records: List[RunRecord], max_candidates: Optional[int], *, + mode: str = "max", + stop_files: Optional[List[str]] = None, + plateau_threshold: int = DEFAULT_PLATEAU_THRESHOLD, + plateau_min_delta: float = DEFAULT_PLATEAU_MIN_DELTA, + hard_crash_threshold: int = DEFAULT_HARD_CRASH_THRESHOLD, manual_stop: bool = False, -) -> None: +) -> Dict[str, Any]: + guard = load_campaign_guard() attempts = len([r for r in records if r.status in {"keep", "discard", "crash"}]) if manual_stop: - state = { - "schema_version": "nvflare.autofl.skill_state.v1", - "candidate_cap": max_candidates, - "candidate_attempts": attempts, - "decision": "manual_stop", - "next_action": "final_report", - "final_response_allowed": True, - } + state = guard.guard_state( + results_path, + max_candidates=max_candidates, + stop_files=stop_files, + plateau_threshold=plateau_threshold, + min_delta=plateau_min_delta, + hard_crash_threshold=hard_crash_threshold, + mode=mode, + ) + state.update( + { + "candidate_attempts": attempts, + "decision": "stop", + "reason": "manual_interrupt", + "next_action": "final_report", + "final_response_allowed": True, + "agent_instruction": "Final report is allowed because the campaign was manually interrupted.", + } + ) write_json(path, state) - return + return state if any(r.status == INFRASTRUCTURE_RETRY for r in records): - state = { - "schema_version": "nvflare.autofl.skill_state.v1", - "candidate_cap": max_candidates, - "candidate_attempts": attempts, - "decision": "retry_infrastructure", - "next_action": "rerun_with_escalated_execution", - "final_response_allowed": False, - } - write_json(path, state) - return - - if max_candidates is None: - state = { - "schema_version": "nvflare.autofl.skill_state.v1", - "candidate_cap": None, - "candidate_attempts": attempts, - "decision": "continue", - "next_action": "launch_next_candidate", - "final_response_allowed": False, - } + state = guard.guard_state( + results_path, + max_candidates=max_candidates, + stop_files=stop_files, + plateau_threshold=plateau_threshold, + min_delta=plateau_min_delta, + hard_crash_threshold=hard_crash_threshold, + mode=mode, + ) + state.update( + { + "candidate_attempts": attempts, + "decision": "retry_infrastructure", + "reason": "infrastructure_retry", + "next_action": "rerun_with_escalated_execution", + "final_response_allowed": False, + "agent_instruction": ( + "Do not produce a final answer. Rerun the same command with escalated execution or repaired " + "runtime permissions; infrastructure retries do not count against the candidate budget." + ), + } + ) write_json(path, state) - return - - state = { - "schema_version": "nvflare.autofl.skill_state.v1", - "candidate_cap": max_candidates, - "candidate_attempts": attempts, - "decision": "stop" if attempts >= max_candidates else "continue", - "next_action": "final_report" if attempts >= max_candidates else "launch_next_candidate", - "final_response_allowed": attempts >= max_candidates, - } + return state + + state = guard.guard_state( + results_path, + max_candidates=max_candidates, + stop_files=stop_files, + plateau_threshold=plateau_threshold, + min_delta=plateau_min_delta, + hard_crash_threshold=hard_crash_threshold, + mode=mode, + ) write_json(path, state) + return state def write_progress(path: Path, records: List[RunRecord], mode: str) -> None: @@ -1151,11 +1242,48 @@ def write_report(path: Path, config: Dict[str, Any], records: List[RunRecord], a path.write_text("\n".join(lines) + "\n", encoding="utf-8") +def candidate_attempts(records: List[RunRecord]) -> int: + return len([r for r in records if r.status in {"keep", "discard", "crash"}]) + + +def campaign_summary( + autofl_yaml: Path, + results: Path, + state: Path, + progress: Path, + report: Path, + records: List[RunRecord], + state_payload: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + payload = { + "autofl_yaml": str(autofl_yaml.resolve()), + "results": str(results.resolve()), + "state": str(state.resolve()), + "progress": str(progress.resolve()), + "report": str(report.resolve()), + "candidate_attempts": candidate_attempts(records), + } + if state_payload: + for key in ["decision", "reason", "next_action", "final_response_allowed", "agent_instruction"]: + if key in state_payload: + payload[key] = state_payload[key] + return payload + + def main(argv: Optional[Sequence[str]] = None) -> int: args = parse_args(argv) if args.max_candidates is not None and args.max_candidates < 1: print("--max-candidates must be positive when provided", file=sys.stderr) return 2 + if args.plateau_threshold < 1: + print("--plateau-threshold must be positive", file=sys.stderr) + return 2 + if args.plateau_min_delta < 0: + print("--plateau-min-delta must be non-negative", file=sys.stderr) + return 2 + if args.hard_crash_threshold < 0: + print("--hard-crash-threshold must be non-negative", file=sys.stderr) + return 2 job = Path(args.job).resolve() cwd = job.parent autofl_yaml = resolve_output_path(cwd, args.autofl_yaml) @@ -1164,6 +1292,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: progress = resolve_output_path(cwd, args.progress) report = resolve_output_path(cwd, args.report) output_root = resolve_output_path(cwd, args.output_root) + stop_files = resolve_stop_files(cwd, args.stop_file) output_root.mkdir(parents=True, exist_ok=True) schema = load_mutation_schema(cwd) profile_budget = h100_comparison_budget(schema) @@ -1218,21 +1347,23 @@ def main(argv: Optional[Sequence[str]] = None) -> int: ) records.append(baseline_record) write_results(results, records) - write_state(state, records, args.max_candidates) + state_payload = write_state( + state, + results, + records, + args.max_candidates, + mode=args.mode, + stop_files=stop_files, + plateau_threshold=args.plateau_threshold, + plateau_min_delta=args.plateau_min_delta, + hard_crash_threshold=args.hard_crash_threshold, + ) write_progress(progress, records, args.mode) write_report(report, config, records, args) if baseline_record.status == INFRASTRUCTURE_RETRY: print( json.dumps( - { - "autofl_yaml": str(autofl_yaml.resolve()), - "results": str(results.resolve()), - "state": str(state.resolve()), - "progress": str(progress.resolve()), - "report": str(report.resolve()), - "candidate_attempts": 0, - "next_action": "rerun_with_escalated_execution", - }, + campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), indent=2, sort_keys=True, ) @@ -1262,42 +1393,58 @@ def main(argv: Optional[Sequence[str]] = None) -> int: records.append(record) finalize_candidates(records, args.mode) write_results(results, records) - write_state(state, records, args.max_candidates) + state_payload = write_state( + state, + results, + records, + args.max_candidates, + mode=args.mode, + stop_files=stop_files, + plateau_threshold=args.plateau_threshold, + plateau_min_delta=args.plateau_min_delta, + hard_crash_threshold=args.hard_crash_threshold, + ) write_progress(progress, records, args.mode) write_report(report, config, records, args) if record.status == INFRASTRUCTURE_RETRY: print( json.dumps( - { - "autofl_yaml": str(autofl_yaml.resolve()), - "results": str(results.resolve()), - "state": str(state.resolve()), - "progress": str(progress.resolve()), - "report": str(report.resolve()), - "candidate_attempts": len([r for r in records if r.status in {"keep", "discard", "crash"}]), - "next_action": "rerun_with_escalated_execution", - }, + campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), indent=2, sort_keys=True, ) ) return 75 + if state_payload.get("next_action") == "run_literature_loop": + print( + json.dumps( + campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), + indent=2, + sort_keys=True, + ) + ) + return 0 + if state_payload.get("final_response_allowed"): + break except KeyboardInterrupt: write_results(results, records) - write_state(state, records, args.max_candidates, manual_stop=True) + state_payload = write_state( + state, + results, + records, + args.max_candidates, + mode=args.mode, + stop_files=stop_files, + plateau_threshold=args.plateau_threshold, + plateau_min_delta=args.plateau_min_delta, + hard_crash_threshold=args.hard_crash_threshold, + manual_stop=True, + ) write_progress(progress, records, args.mode) write_report(report, config, records, args) print( json.dumps( - { - "autofl_yaml": str(autofl_yaml.resolve()), - "results": str(results.resolve()), - "state": str(state.resolve()), - "progress": str(progress.resolve()), - "report": str(report.resolve()), - "candidate_attempts": len([r for r in records if r.status in {"keep", "discard", "crash"}]), - "next_action": "final_report", - }, + campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), indent=2, sort_keys=True, ) @@ -1305,17 +1452,20 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return 130 write_report(report, config, records, args) - candidate_attempts = len([r for r in records if r.status in {"keep", "discard", "crash"}]) + state_payload = write_state( + state, + results, + records, + args.max_candidates, + mode=args.mode, + stop_files=stop_files, + plateau_threshold=args.plateau_threshold, + plateau_min_delta=args.plateau_min_delta, + hard_crash_threshold=args.hard_crash_threshold, + ) print( json.dumps( - { - "autofl_yaml": str(autofl_yaml.resolve()), - "results": str(results.resolve()), - "state": str(state.resolve()), - "progress": str(progress.resolve()), - "report": str(report.resolve()), - "candidate_attempts": candidate_attempts, - }, + campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), indent=2, sort_keys=True, ) diff --git a/tests/unit_test/tool/autofl_skill_campaign_guard_test.py b/tests/unit_test/tool/autofl_skill_campaign_guard_test.py new file mode 100644 index 0000000000..4ce2557eed --- /dev/null +++ b/tests/unit_test/tool/autofl_skill_campaign_guard_test.py @@ -0,0 +1,163 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import csv +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +RESULT_FIELDS = [ + "status", + "name", + "score", + "runtime_seconds", + "changed_files", + "diff_summary", + "run_command", + "artifacts", + "failure_reason", +] + + +def _load_guard(): + repo_root = Path(__file__).parents[3] + guard_path = repo_root / "skills" / "nvflare-autofl" / "scripts" / "campaign_guard.py" + spec = importlib.util.spec_from_file_location("nvflare_autofl_skill_campaign_guard_test", guard_path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _write_results(path, rows): + with path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=RESULT_FIELDS, delimiter="\t") + writer.writeheader() + for row in rows: + writer.writerow(row) + + +def _row(status, name, score="", diff_summary="candidate", run_command="python job.py"): + return { + "status": status, + "name": name, + "score": score, + "runtime_seconds": "10.0", + "changed_files": "none", + "diff_summary": diff_summary, + "run_command": run_command, + "artifacts": "/tmp/run", + "failure_reason": "", + } + + +def test_guard_continues_uncapped_before_plateau(): + guard = _load_guard() + rows = [ + _row("baseline", "baseline", "0.85"), + _row("discard", "batch_size_64", "0.851"), + _row("discard", "lr_0p01", "0.850"), + ] + + state = guard.guard_state_for_rows(rows, plateau_threshold=4) + + assert state["schema_version"] == "nvflare.autofl.campaign_state.v1" + assert state["decision"] == "continue" + assert state["next_action"] == "launch_next_candidate" + assert state["final_response_allowed"] is False + assert state["candidate_cap"] is None + assert state["candidate_attempts"] == 2 + assert state["best_score"] == 0.851 + + +def test_guard_routes_plateau_to_literature_without_finalizing(): + guard = _load_guard() + rows = [_row("baseline", "baseline", "0.85")] + rows.extend(_row("discard", f"candidate_{idx}", "0.840") for idx in range(4)) + + state = guard.guard_state_for_rows(rows, plateau_threshold=4) + + assert state["decision"] == "continue" + assert state["reason"] == "plateau_literature" + assert state["next_action"] == "run_literature_loop" + assert state["final_response_allowed"] is False + assert state["best_score"] == 0.85 + assert "Do not produce a final answer" in state["agent_instruction"] + + +def test_guard_literature_event_resets_plateau_clock(): + guard = _load_guard() + rows = [_row("baseline", "baseline", "0.85")] + rows.extend(_row("discard", f"before_lit_{idx}", "0.840") for idx in range(4)) + rows.append(_row("literature", "literature_review", "", diff_summary="literature review")) + rows.extend(_row("discard", f"after_lit_{idx}", "0.841") for idx in range(2)) + + state = guard.guard_state_for_rows(rows, plateau_threshold=4) + + assert state["reason"] == "continue" + assert state["next_action"] == "launch_next_candidate" + assert state["plateau"]["last_literature_event_index"] == 5 + assert state["plateau"]["scored_since_reset"] == 2 + + +def test_guard_counts_candidate_with_baseline_in_description(): + guard = _load_guard() + rows = [ + _row("baseline", "baseline", "0.85"), + _row("discard", "weighted_rerun", "0.8503", diff_summary="weighted baseline escalated rerun"), + ] + + state = guard.guard_state_for_rows(rows, max_candidates=1) + + assert state["candidate_attempts"] == 1 + assert state["decision"] == "stop" + assert state["reason"] == "candidate_cap_exhausted" + + +def test_guard_cli_writes_campaign_state_json(tmp_path): + repo_root = Path(__file__).parents[3] + guard_path = repo_root / "skills" / "nvflare-autofl" / "scripts" / "campaign_guard.py" + results_path = tmp_path / "results.tsv" + state_path = tmp_path / "state.json" + _write_results( + results_path, + [ + _row("baseline", "baseline", "0.85"), + _row("discard", "candidate_1", "0.840"), + _row("discard", "candidate_2", "0.841"), + ], + ) + + proc = subprocess.run( + [ + sys.executable, + str(guard_path), + str(results_path), + "--state", + str(state_path), + "--plateau-threshold", + "2", + "--format", + "json", + ], + text=True, + capture_output=True, + check=True, + ) + + payload = json.loads(proc.stdout) + assert json.loads(state_path.read_text(encoding="utf-8")) == payload + assert payload["next_action"] == "run_literature_loop" diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index f629931bf4..7901c82094 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -13,6 +13,7 @@ # limitations under the License. import importlib.util +import json import sys from pathlib import Path from types import SimpleNamespace @@ -193,3 +194,73 @@ def test_run_stops_on_stale_partial_simulator_aggregation(tmp_path): assert runtime < 5 assert "SIMULATOR_STALL: partial simulator aggregation made no server-side progress" in output assert "Aggregated 1/8 results" in log_text + + +def test_runner_state_routes_plateau_to_literature_checkpoint(tmp_path): + runner = _load_runner() + records = [ + runner.RunRecord("baseline", "baseline", 0.85, 1.0, "none", "baseline", "python job.py", "/tmp/baseline"), + runner.RunRecord("discard", "candidate_1", 0.84, 1.0, "none", "candidate", "python job.py", "/tmp/c1"), + runner.RunRecord("discard", "candidate_2", 0.84, 1.0, "none", "candidate", "python job.py", "/tmp/c2"), + ] + results_path = tmp_path / "results.tsv" + state_path = tmp_path / "state.json" + runner.write_results(results_path, records) + + state = runner.write_state( + state_path, + results_path, + records, + None, + plateau_threshold=2, + ) + + assert state["schema_version"] == "nvflare.autofl.campaign_state.v1" + assert state["reason"] == "plateau_literature" + assert state["next_action"] == "run_literature_loop" + assert state["final_response_allowed"] is False + assert state == json.loads(state_path.read_text(encoding="utf-8")) + + +def test_runner_state_finalizes_after_explicit_candidate_cap(tmp_path): + runner = _load_runner() + records = [ + runner.RunRecord("baseline", "baseline", 0.85, 1.0, "none", "baseline", "python job.py", "/tmp/baseline"), + runner.RunRecord("discard", "candidate_1", 0.84, 1.0, "none", "candidate", "python job.py", "/tmp/c1"), + ] + results_path = tmp_path / "results.tsv" + state_path = tmp_path / "state.json" + runner.write_results(results_path, records) + + state = runner.write_state(state_path, results_path, records, 1) + + assert state["decision"] == "stop" + assert state["reason"] == "candidate_cap_exhausted" + assert state["next_action"] == "final_report" + assert state["final_response_allowed"] is True + + +def test_runner_state_marks_infrastructure_retry_non_final(tmp_path): + runner = _load_runner() + records = [ + runner.RunRecord( + runner.INFRASTRUCTURE_RETRY, + "baseline", + None, + 1.0, + "none", + "baseline", + "python job.py", + "/tmp/baseline", + ) + ] + results_path = tmp_path / "results.tsv" + state_path = tmp_path / "state.json" + runner.write_results(results_path, records) + + state = runner.write_state(state_path, results_path, records, None) + + assert state["decision"] == "retry_infrastructure" + assert state["reason"] == "infrastructure_retry" + assert state["next_action"] == "rerun_with_escalated_execution" + assert state["final_response_allowed"] is False From 780fe21cf025285f287d3706ff8f042695d4a642 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Fri, 26 Jun 2026 14:11:29 -0400 Subject: [PATCH 10/23] Report CIFAR Auto-FL score as test accuracy --- .../data/cifar10_data_utils.py | 13 ++--- .../auto-fl-research/scripts/extract_score.py | 2 +- .../auto-fl-research/tasks/cifar10/client.py | 54 ++++++++++--------- .../tasks/cifar10/mutation_schema.yaml | 7 ++- .../auto-fl-research/tasks/cifar10/profile.md | 5 +- skills/nvflare-autofl/SKILL.md | 11 ++-- .../scripts/run_job_campaign.py | 25 ++++++--- .../research/autofl_extract_score_test.py | 41 ++++++++++++++ .../tool/autofl_skill_runner_test.py | 20 +++++++ 9 files changed, 129 insertions(+), 49 deletions(-) create mode 100644 tests/unit_test/research/autofl_extract_score_test.py diff --git a/research/auto-fl-research/data/cifar10_data_utils.py b/research/auto-fl-research/data/cifar10_data_utils.py index fd25e5e186..2c392d0d1e 100644 --- a/research/auto-fl-research/data/cifar10_data_utils.py +++ b/research/auto-fl-research/data/cifar10_data_utils.py @@ -44,6 +44,7 @@ def get_site_class_summary(train_label, site_idx): def create_datasets(site_name, train_idx_root, central=False): + """Return a site-local CIFAR-10 train split and the global CIFAR-10 test set.""" transform_train = transforms.Compose( [ transforms.ToTensor(), @@ -84,17 +85,17 @@ def create_datasets(site_name, train_idx_root, central=False): download=True, transform=transform_train, ) - valid_dataset = torchvision.datasets.CIFAR10( + test_dataset = torchvision.datasets.CIFAR10( root=CIFAR10_ROOT, train=False, download=True, transform=transform_valid, ) - return train_dataset, valid_dataset + return train_dataset, test_dataset -def create_data_loaders(train_dataset, valid_dataset, batch_size, num_workers): +def create_data_loaders(train_dataset, eval_dataset, batch_size, num_workers): train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=batch_size, @@ -103,12 +104,12 @@ def create_data_loaders(train_dataset, valid_dataset, batch_size, num_workers): pin_memory=True, persistent_workers=True if num_workers > 0 else False, ) - valid_loader = torch.utils.data.DataLoader( - valid_dataset, + eval_loader = torch.utils.data.DataLoader( + eval_dataset, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True, persistent_workers=True if num_workers > 0 else False, ) - return train_loader, valid_loader + return train_loader, eval_loader diff --git a/research/auto-fl-research/scripts/extract_score.py b/research/auto-fl-research/scripts/extract_score.py index 87880b1848..3c6b5f93d9 100755 --- a/research/auto-fl-research/scripts/extract_score.py +++ b/research/auto-fl-research/scripts/extract_score.py @@ -23,7 +23,7 @@ from pathlib import Path PRIMARY_MODEL_KEY = "SRV_FL_global_model.pt" -METRIC_KEYS = ["accuracy", "val_accuracy", "test_accuracy", "token_f1"] +METRIC_KEYS = ["test_accuracy", "accuracy", "val_accuracy", "token_f1"] def find_json(result_root: Path) -> Path: diff --git a/research/auto-fl-research/tasks/cifar10/client.py b/research/auto-fl-research/tasks/cifar10/client.py index 793b40bbdd..a29b077e75 100644 --- a/research/auto-fl-research/tasks/cifar10/client.py +++ b/research/auto-fl-research/tasks/cifar10/client.py @@ -158,7 +158,7 @@ def _make_generator(seed): def _create_seeded_data_loaders( train_dataset, - valid_dataset, + test_dataset, batch_size, eval_batch_size, num_workers, @@ -174,8 +174,8 @@ def _create_seeded_data_loaders( worker_init_fn=_seed_worker if num_workers > 0 else None, generator=_make_generator(seed), ) - valid_loader = torch.utils.data.DataLoader( - valid_dataset, + test_loader = torch.utils.data.DataLoader( + test_dataset, batch_size=eval_batch_size, shuffle=False, num_workers=num_workers, @@ -184,7 +184,7 @@ def _create_seeded_data_loaders( worker_init_fn=_seed_worker if num_workers > 0 else None, generator=_make_generator(seed + 1), ) - return train_loader, valid_loader + return train_loader, test_loader def _zero_scaffold_controls(model): @@ -304,13 +304,13 @@ def main(args): criterion_prox = PTFedProxLoss(mu=args.fedproxloss_mu) print(f"Creating datasets for site={site_name}") - train_dataset, valid_dataset = create_datasets( + train_dataset, test_dataset = create_datasets( site_name, train_idx_root=args.train_idx_root, ) - train_loader, valid_loader = _create_seeded_data_loaders( + train_loader, test_loader = _create_seeded_data_loaders( train_dataset, - valid_dataset, + test_dataset, batch_size=args.batch_size, eval_batch_size=args.eval_batch_size, num_workers=args.num_workers, @@ -343,18 +343,21 @@ def main(args): if flare.is_evaluate(): print(f"{site_name}: cross-site evaluation task") - val_acc_global_model = evaluate(model, valid_loader, DEVICE) - print(f"{site_name}: global validation accuracy={100 * val_acc_global_model:.2f}%") + test_acc_global_model = evaluate(model, test_loader, DEVICE) + print(f"{site_name}: global CIFAR-10 test accuracy={100 * test_acc_global_model:.2f}%") summary_writer.add_scalar( - tag="val_acc_global_model", - scalar=val_acc_global_model, + tag="test_acc_global_model", + scalar=test_acc_global_model, global_step=current_round, ) # Cross-site validation expects a metrics-only DXO (DataKind.METRICS). # Sending no params lets FLModelUtils emit DataKind.METRICS instead of WEIGHT_DIFF. flare.send( flare.FLModel( - metrics={"accuracy": val_acc_global_model}, + metrics={ + "accuracy": test_acc_global_model, + "test_accuracy": test_acc_global_model, + }, meta={"NUM_STEPS_CURRENT_ROUND": 0}, ) ) @@ -375,12 +378,13 @@ def main(args): metrics = {} if args.eval_global_every_round: - val_acc_global_model = evaluate(global_model, valid_loader, DEVICE) - metrics["accuracy"] = val_acc_global_model - print(f"{site_name}: global validation accuracy={100 * val_acc_global_model:.2f}%") + test_acc_global_model = evaluate(global_model, test_loader, DEVICE) + metrics["accuracy"] = test_acc_global_model + metrics["test_accuracy"] = test_acc_global_model + print(f"{site_name}: global CIFAR-10 test accuracy={100 * test_acc_global_model:.2f}%") summary_writer.add_scalar( - tag="val_acc_global_model", - scalar=val_acc_global_model, + tag="test_acc_global_model", + scalar=test_acc_global_model, global_step=current_round, ) @@ -446,11 +450,11 @@ def main(args): ) if args.evaluate_local: - val_acc_local_model = evaluate(model, valid_loader, DEVICE) - print(f"{site_name}: local validation accuracy={100 * val_acc_local_model:.2f}%") + test_acc_local_model = evaluate(model, test_loader, DEVICE) + print(f"{site_name}: local CIFAR-10 test accuracy={100 * test_acc_local_model:.2f}%") summary_writer.add_scalar( - tag="val_acc_local_model", - scalar=val_acc_local_model, + tag="test_acc_local_model", + scalar=test_acc_local_model, global_step=global_step, ) else: @@ -496,11 +500,11 @@ def main(args): ) if args.evaluate_local: - val_acc_local_model = evaluate(model, valid_loader, DEVICE) - print(f"{site_name}: local validation accuracy={100 * val_acc_local_model:.2f}%") + test_acc_local_model = evaluate(model, test_loader, DEVICE) + print(f"{site_name}: local CIFAR-10 test accuracy={100 * test_acc_local_model:.2f}%") summary_writer.add_scalar( - tag="val_acc_local_model", - scalar=val_acc_local_model, + tag="test_acc_local_model", + scalar=test_acc_local_model, global_step=global_epoch, ) diff --git a/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml b/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml index 76543395bd..e2c3a17334 100644 --- a/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml +++ b/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml @@ -17,6 +17,7 @@ fixed_invariants: fixed_assets: - create_datasets/create_data_loaders contract - train_idx_root partitioning scheme + - global CIFAR-10 test-set evaluation via torchvision.datasets.CIFAR10(train=False) mutable_args: model_arch: @@ -59,6 +60,7 @@ comparison_budget_args: aggregator: weighted cross_site_eval: true final_eval_clients: site-1 + evaluation_split: cifar10_test run_timeout_seconds: 1200 fixed_within_campaign: - n_clients @@ -71,13 +73,16 @@ comparison_budget_args: - max_model_params - cross_site_eval - final_eval_clients + - evaluation_split note: > Keep communication and data-budget fields fixed within a comparison campaign. aggregation_epochs and local_train_steps are local-compute knobs: local_train_steps=0 means epoch-based training with aggregation_epochs, and local_train_steps>0 means exact optimizer steps per client per round. Vary only one local-compute mode in a narrow sweep and keep candidates - within run_timeout_seconds. + within run_timeout_seconds. The comparable CIFAR-10 score is held-out + test_accuracy from the global CIFAR-10 test set; accuracy is retained only + as a backward-compatible alias. split_cache: > CIFAR-10 train index splits are cached under /tmp/cifar10_splits by n_clients, alpha, and seed. Candidate job names must not create new splits diff --git a/research/auto-fl-research/tasks/cifar10/profile.md b/research/auto-fl-research/tasks/cifar10/profile.md index 7558e124c9..d9aae83685 100644 --- a/research/auto-fl-research/tasks/cifar10/profile.md +++ b/research/auto-fl-research/tasks/cifar10/profile.md @@ -44,6 +44,7 @@ Each experiment should run under a **fixed communication, data, and evaluation b - `max_model_params` - whether cross-site evaluation is enabled - `final_eval_clients` +- the evaluation split (`torchvision.datasets.CIFAR10(train=False)`, the held-out CIFAR-10 test set) Some of these values are technically mutable in `mutation_schema.yaml`, but changing them starts a new comparison budget. Do not compare scores across runs with different values for the fixed budget fields above unless the run is explicitly labeled as a new campaign or subcampaign. Architecture-search scores must be labeled with their `model_arch` and `max_model_params`; do not mix them with optimizer-only `moderate_cnn` results as if they were the same search. @@ -61,13 +62,13 @@ Default H100 candidate budget: - `--max_model_params 5000000` - `--aggregator weighted` - cross-site evaluation enabled -- final global evaluation on `site-1` +- final global CIFAR-10 test-set evaluation on `site-1` - `--eval_batch_size 1024` - `RUN_TIMEOUT_SECONDS=1200` - deterministic PyTorch/DataLoader training enabled Each candidate targets a capped run on one local H100. The 80 GB H100 can usually support several same-budget candidates concurrently; if runs consistently finish much sooner, sweep local compute first with either `aggregation_epochs` or `local_train_steps`. If they time out or hit CUDA OOM, reduce candidate parallelism before changing communication, model, parameter-cap, or data contracts. -The current CIFAR-10 validation loader is identical on every simulated client, so final scoring evaluates the server/global model on `site-1` by default and keeps the output in NVFlare's `cross_site_val/cross_val_results.json` path. Use `--final_eval_clients all` only for an audit run or after changing validation to be site-specific. +The CIFAR-10 evaluation loader is the held-out global CIFAR-10 test set (`torchvision.datasets.CIFAR10(train=False)`) and is identical on every simulated client. Final scoring evaluates the server/global model on `site-1` by default, reports both `test_accuracy` and the backward-compatible `accuracy` alias, and keeps the output in NVFlare's `cross_site_val/cross_val_results.json` path. Use `--final_eval_clients all` only for an audit run or after changing evaluation to be site-specific. Training splits are cached by fixed data-budget fields under `/tmp/cifar10_splits/autofl_cifar10_sites_alpha_seed`. Do not make the split path depend on the candidate `--name`; repeated candidates with the same `n_clients`, `alpha`, and `seed` should reuse the same `.npy` indices. Client training derives stable per-site RNG seeds from `--seed`, enables PyTorch deterministic algorithms, disables cuDNN benchmarking, and seeds DataLoader shuffling/workers. Treat `--no_deterministic_training` as a separate noisy subcampaign. diff --git a/skills/nvflare-autofl/SKILL.md b/skills/nvflare-autofl/SKILL.md index c0d5f71a10..d32658fc26 100644 --- a/skills/nvflare-autofl/SKILL.md +++ b/skills/nvflare-autofl/SKILL.md @@ -88,7 +88,7 @@ optimization once. For long-running and simulator-stall handling, read Read `autofl.yaml` and show the user a concise campaign summary: - **Editable**: metric, environment, candidate budget, tunables, artifact - locations, source hash, and importer version. + locations, metric source, source hash, and importer version. - **Unresolved**: dynamic defaults, unsupported Python semantics, missing metric sources, unknown data paths, or any low-confidence fields. - **Allowed**: files the agent may edit, fixed-budget fields it must preserve, @@ -140,8 +140,8 @@ source-code mutations that the runner cannot express yet. 2. Propose and run a candidate tied to supported tunables or allowed files. 3. Validate importability and fixed-budget comparability. 4. Extract the requested metric from NVFLARE artifacts/logs. -5. Update `results.tsv`, mark non-survivors as `discard`, crashes as `crash`, - the survivor as `keep`, and unresolved active rows as `candidate`. +5. Update `results.tsv`: non-survivors=`discard`, crashes=`crash`, + survivor=`keep`, unresolved=`candidate`; prefer explicit metrics such as `test_accuracy`. 6. Refresh `progress.png`, update campaign state, and launch the next comparable candidate batch unless the code-owned state says `final_response_allowed=true` or production policy blocks execution. @@ -195,6 +195,5 @@ Only produce a final answer for a campaign when the code-owned campaign state reports `final_response_allowed=true`, for example because the user manually stopped it, an explicit cap is exhausted, production policy blocks execution, or a hard safety/runtime blocker prevents further comparable runs. At that point, -end with finalized `results.tsv`, refreshed `progress.png`, a concise report -covering baseline, best score, artifacts, failures, product friction, and -reproduction commands, plus absolute paths to all final artifacts. +end with finalized `results.tsv`, refreshed `progress.png`, and a concise report covering +baseline, best score, metric source, failures, friction, commands, and absolute artifact paths. diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index 3ec02bde4c..9943d84b38 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -106,6 +106,10 @@ "final_eval_clients": "final_eval_clients", } +METRIC_ALIASES = { + "accuracy": ["test_accuracy", "accuracy"], +} + @dataclass class RunRecord: @@ -530,21 +534,25 @@ def extract_result_dir(output: str) -> Optional[Path]: def find_metric_value(payload: Any, metric: str) -> Optional[float]: + metric_keys = METRIC_ALIASES.get(metric, [metric]) if isinstance(payload, dict): for key in ("final_aggregated_metrics", "best_metrics", "metrics"): - value = metric_from_list(payload.get(key), metric) - if value is not None: - return value - if metric in payload and isinstance(payload[metric], (int, float)): - return float(payload[metric]) + for metric_key in metric_keys: + value = metric_from_list(payload.get(key), metric_key) + if value is not None: + return value + for metric_key in metric_keys: + if metric_key in payload and isinstance(payload[metric_key], (int, float)): + return float(payload[metric_key]) for value in payload.values(): score = find_metric_value(value, metric) if score is not None: return score elif isinstance(payload, list): - value = metric_from_list(payload, metric) - if value is not None: - return value + for metric_key in metric_keys: + value = metric_from_list(payload, metric_key) + if value is not None: + return value for item in payload: score = find_metric_value(item, metric) if score is not None: @@ -1210,6 +1218,7 @@ def write_report(path: Path, config: Dict[str, Any], records: List[RunRecord], a "# Auto-FL Report", "", f"Objective: optimize `{args.metric}` in `{args.target_env}`.", + f"Metric extraction order: `{', '.join(METRIC_ALIASES.get(args.metric, [args.metric]))}`.", f"Candidate budget: `{candidate_budget}`.", f"Config: `{args.autofl_yaml}`.", f"Fixed budget: `{json.dumps(config.get('budget', {}).get('fixed_training_budget', {}), sort_keys=True)}`.", diff --git a/tests/unit_test/research/autofl_extract_score_test.py b/tests/unit_test/research/autofl_extract_score_test.py new file mode 100644 index 0000000000..8bb5d97e87 --- /dev/null +++ b/tests/unit_test/research/autofl_extract_score_test.py @@ -0,0 +1,41 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib.util +import sys +from pathlib import Path + + +def _load_extract_score(): + repo_root = Path(__file__).parents[3] + script_path = repo_root / "research" / "auto-fl-research" / "scripts" / "extract_score.py" + spec = importlib.util.spec_from_file_location("nvflare_autofl_extract_score", script_path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_extract_score_prefers_explicit_test_accuracy(): + extract_score = _load_extract_score() + data = { + "site-1": { + extract_score.PRIMARY_MODEL_KEY: { + "accuracy": 0.5, + "test_accuracy": 0.8, + } + } + } + + assert extract_score.extract_score(data) == 0.8 diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index 7901c82094..7efa6d2a20 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -53,6 +53,26 @@ def test_candidate_plan_skips_out_of_bounds_learning_rate(): assert "--lr 0.2" not in candidate_commands +def test_runner_prefers_explicit_test_accuracy_alias(tmp_path): + runner = _load_runner() + result_path = tmp_path / "cross_val_results.json" + result_path.write_text( + json.dumps( + { + "site-1": { + "SRV_FL_global_model.pt": { + "accuracy": 0.5, + "test_accuracy": 0.8, + } + } + } + ), + encoding="utf-8", + ) + + assert runner.extract_score(tmp_path, "accuracy") == 0.8 + + def test_profile_budget_suppresses_duplicate_imported_fixed_budget_args(): runner = _load_runner() config = {"budget": {"fixed_training_budget": {"num_clients": 8, "num_rounds": 10}}} From 84be7f9be7de7c2ea6ef70d537a80600ef183fc8 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Fri, 26 Jun 2026 14:33:25 -0400 Subject: [PATCH 11/23] Standardize Auto-FL optimization metric contract --- nvflare/app_common/autofl/job_importer.py | 28 +++- .../tasks/cifar10/mutation_schema.yaml | 6 + skills/nvflare-autofl/SKILL.md | 2 +- .../scripts/run_job_campaign.py | 130 ++++++++++++++---- .../app_common/autofl/job_importer_test.py | 17 ++- .../tool/autofl_skill_runner_test.py | 30 +++- 6 files changed, 175 insertions(+), 38 deletions(-) diff --git a/nvflare/app_common/autofl/job_importer.py b/nvflare/app_common/autofl/job_importer.py index dc93e5beaf..13b911ab14 100644 --- a/nvflare/app_common/autofl/job_importer.py +++ b/nvflare/app_common/autofl/job_importer.py @@ -151,6 +151,7 @@ def import_job( unresolved.append(_unresolved("job.train_script", "no train_script was found or resolved")) metric_name, metric_source, metric_issue = self._resolve_metric(metric, job_call, index.parser_args) + objective = _objective_contract(metric_name, mode, metric_source) if metric_issue: unresolved.append(metric_issue) @@ -208,7 +209,7 @@ def import_job( }, }, "job": job_payload, - "objective": {"metric": metric_name, "mode": mode, "source": metric_source}, + "objective": objective, "budget": budget, "environment": environment, "search_space": {"suggested": search_space}, @@ -217,7 +218,14 @@ def import_job( "result_root": "autofl_runs", }, "trust_contract": { - "extracted": _trust_extracted(job_call, env_call, train_script, budget, metric_name, search_space), + "extracted": _trust_extracted( + job_call, + env_call, + train_script, + budget, + objective, + search_space, + ), "unresolved": list(unresolved), "allowed_edit_paths": allowed_edit_paths, "agent_controls": { @@ -735,7 +743,7 @@ def _trust_extracted( env_call: Optional[CallInfo], train_script: Optional[Path], budget: Dict[str, Any], - metric_name: str, + objective: Dict[str, Any], search_space: Dict[str, Any], ) -> List[Dict[str, Any]]: extracted = [] @@ -746,7 +754,8 @@ def _trust_extracted( extracted.append({"field": "environment.discovered", "value": env_call.name}) if train_script: extracted.append({"field": "job.train_script", "value": train_script.name}) - extracted.append({"field": "objective.metric", "value": metric_name}) + extracted.append({"field": "objective.metric", "value": objective["metric"]}) + extracted.append({"field": "objective.optimization_metric", "value": objective["optimization_metric"]}) if "fixed_training_budget" in budget: extracted.append({"field": "budget.fixed_training_budget", "value": budget["fixed_training_budget"]}) if search_space: @@ -754,6 +763,17 @@ def _trust_extracted( return extracted +def _objective_contract(metric_name: str, mode: str, source: str) -> Dict[str, Any]: + return { + "metric": metric_name, + "requested_metric": metric_name, + "optimization_metric": metric_name, + "metric_extraction_order": [metric_name], + "mode": mode, + "source": source, + } + + def _has_main_entrypoint(tree: ast.AST) -> bool: return any(isinstance(node, ast.FunctionDef) and node.name == "main" for node in ast.walk(tree)) diff --git a/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml b/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml index e2c3a17334..fa602b88e0 100644 --- a/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml +++ b/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml @@ -1,6 +1,12 @@ target: task-local client.py, job.py, model.py; shared custom_aggregators.py goal: mutate local client behavior and bounded registered model architectures without changing the federated protocol; scaffold metadata is allowed only when explicitly selected +objective: + requested_metric: accuracy + optimization_metric: test_accuracy + metric_extraction_order: [test_accuracy, accuracy] + metric_source: held-out global CIFAR-10 test set via torchvision.datasets.CIFAR10(train=False) + fixed_invariants: api_loop: - flare.init() diff --git a/skills/nvflare-autofl/SKILL.md b/skills/nvflare-autofl/SKILL.md index d32658fc26..8c30cde888 100644 --- a/skills/nvflare-autofl/SKILL.md +++ b/skills/nvflare-autofl/SKILL.md @@ -88,7 +88,7 @@ optimization once. For long-running and simulator-stall handling, read Read `autofl.yaml` and show the user a concise campaign summary: - **Editable**: metric, environment, candidate budget, tunables, artifact - locations, metric source, source hash, and importer version. + locations, `objective.optimization_metric`, metric source, source hash, and importer version. - **Unresolved**: dynamic defaults, unsupported Python semantics, missing metric sources, unknown data paths, or any low-confidence fields. - **Allowed**: files the agent may edit, fixed-budget fields it must preserve, diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index 9943d84b38..f87c4d45ff 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -106,10 +106,6 @@ "final_eval_clients": "final_eval_clients", } -METRIC_ALIASES = { - "accuracy": ["test_accuracy", "accuracy"], -} - @dataclass class RunRecord: @@ -483,6 +479,12 @@ def read_yaml(path: Path) -> Dict[str, Any]: return yaml.safe_load(path.read_text(encoding="utf-8")) or {} +def write_yaml(path: Path, data: Dict[str, Any]) -> None: + if yaml is None: + raise RuntimeError("PyYAML is required to write autofl.yaml") + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + def write_json(path: Path, data: Dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") @@ -533,8 +535,64 @@ def extract_result_dir(output: str) -> Optional[Path]: return None -def find_metric_value(payload: Any, metric: str) -> Optional[float]: - metric_keys = METRIC_ALIASES.get(metric, [metric]) +def objective_contract(config: Dict[str, Any], requested_metric: str) -> Dict[str, Any]: + objective = config.get("objective", {}) + if not isinstance(objective, dict): + objective = {} + metric = str(objective.get("metric") or requested_metric) + requested = str(objective.get("requested_metric") or metric) + optimization = str(objective.get("optimization_metric") or metric) + extraction_order = objective.get("metric_extraction_order") + if not isinstance(extraction_order, list) or not extraction_order: + extraction_order = [optimization] + extraction_order = [str(item) for item in extraction_order if item] + if optimization not in extraction_order: + extraction_order.insert(0, optimization) + return { + **objective, + "metric": metric, + "requested_metric": requested, + "optimization_metric": optimization, + "metric_extraction_order": extraction_order, + } + + +def apply_metric_contract( + config: Dict[str, Any], requested_metric: str, schema: Optional[Dict[str, Any]] +) -> Dict[str, Any]: + objective = objective_contract(config, requested_metric) + schema_objective = (schema or {}).get("objective", {}) + if isinstance(schema_objective, dict): + schema_requested = schema_objective.get("requested_metric") or schema_objective.get("metric") + if not schema_requested or schema_requested == objective["requested_metric"]: + for key in ("optimization_metric", "metric_extraction_order", "metric_source"): + if key in schema_objective: + objective[key] = schema_objective[key] + config["objective"] = objective_contract({"objective": objective}, requested_metric) + return config + + +def metric_extraction_order(config: Dict[str, Any], requested_metric: str) -> List[str]: + return list(objective_contract(config, requested_metric)["metric_extraction_order"]) + + +def optimization_metric(config: Dict[str, Any], requested_metric: str) -> str: + return str(objective_contract(config, requested_metric)["optimization_metric"]) + + +def metric_source(config: Dict[str, Any]) -> str: + source = config.get("objective", {}).get("metric_source", "") + return str(source) if source else "NVFlare metric artifacts" + + +def normalize_metric_order(metrics: Sequence[str] | str) -> List[str]: + if isinstance(metrics, str): + return [metrics] + return [str(metric) for metric in metrics if metric] + + +def find_metric_value(payload: Any, metric_order: Sequence[str] | str) -> Optional[float]: + metric_keys = normalize_metric_order(metric_order) if isinstance(payload, dict): for key in ("final_aggregated_metrics", "best_metrics", "metrics"): for metric_key in metric_keys: @@ -545,7 +603,7 @@ def find_metric_value(payload: Any, metric: str) -> Optional[float]: if metric_key in payload and isinstance(payload[metric_key], (int, float)): return float(payload[metric_key]) for value in payload.values(): - score = find_metric_value(value, metric) + score = find_metric_value(value, metric_keys) if score is not None: return score elif isinstance(payload, list): @@ -554,7 +612,7 @@ def find_metric_value(payload: Any, metric: str) -> Optional[float]: if value is not None: return value for item in payload: - score = find_metric_value(item, metric) + score = find_metric_value(item, metric_keys) if score is not None: return score return None @@ -571,7 +629,8 @@ def metric_from_list(items: Any, metric: str) -> Optional[float]: return None -def extract_score(artifact_root: Path, metric: str) -> Optional[float]: +def extract_score(artifact_root: Path, metrics: Sequence[str] | str) -> Optional[float]: + metric_order = normalize_metric_order(metrics) metric_files = list(artifact_root.glob("**/metrics_summary.json")) + list( artifact_root.glob("**/cross_val_results.json") ) @@ -580,14 +639,13 @@ def extract_score(artifact_root: Path, metric: str) -> Optional[float]: payload = json.loads(path.read_text(encoding="utf-8")) except Exception: continue - score = find_metric_value(payload, metric) + score = find_metric_value(payload, metric_order) if score is not None: return score - number_patterns = [ - rf"{re.escape(metric)}[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)", - r"Accuracy of the network[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)", - ] + number_patterns = [rf"{re.escape(metric)}[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)" for metric in metric_order] + if "accuracy" in metric_order: + number_patterns.append(r"Accuracy of the network[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)") for path in artifact_root.glob("**/*.log"): try: text = path.read_text(encoding="utf-8", errors="replace") @@ -977,7 +1035,7 @@ def run_job( output_root: Path, timeout: int, simulator_no_progress_timeout: int, - metric: str, + metrics: Sequence[str], config: Dict[str, Any], ) -> RunRecord: log_path = output_root / run_def.name / "run.log" @@ -1016,10 +1074,10 @@ def run_job( run_def.status = "crash" run_def.failure_reason = f"exit_code={rc}" else: - score = extract_score(artifact_dir, metric) + score = extract_score(artifact_dir, metrics) if score is None: run_def.status = "crash" - run_def.failure_reason = f"metric '{metric}' not found" + run_def.failure_reason = f"metrics '{', '.join(metrics)}' not found" else: run_def.score = score @@ -1161,7 +1219,7 @@ def write_state( return state -def write_progress(path: Path, records: List[RunRecord], mode: str) -> None: +def write_progress(path: Path, records: List[RunRecord], mode: str, metric_label: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) try: from PIL import Image, ImageDraw, ImageFont @@ -1175,7 +1233,12 @@ def write_progress(path: Path, records: List[RunRecord], mode: str) -> None: image = Image.new("RGB", (width, height), "white") draw = ImageDraw.Draw(image) font = ImageFont.load_default() - draw.text((margin, 24), f"Auto-FL Progress: {len(records)} rows, {len(scores)} scored", fill="black", font=font) + draw.text( + (margin, 24), + f"Auto-FL Progress ({metric_label}): {len(records)} rows, {len(scores)} scored", + fill="black", + font=font, + ) draw.line((margin, height - margin, width - margin, height - margin), fill=(80, 80, 80), width=2) draw.line((margin, margin, margin, height - margin), fill=(80, 80, 80), width=2) @@ -1214,11 +1277,14 @@ def write_report(path: Path, config: Dict[str, Any], records: List[RunRecord], a candidate_budget = ( str(args.max_candidates) if args.max_candidates is not None else "uncapped; runs until manual interruption" ) + objective = objective_contract(config, args.metric) lines = [ "# Auto-FL Report", "", - f"Objective: optimize `{args.metric}` in `{args.target_env}`.", - f"Metric extraction order: `{', '.join(METRIC_ALIASES.get(args.metric, [args.metric]))}`.", + f"Objective: optimize `{objective['optimization_metric']}` in `{args.target_env}`.", + f"Requested metric: `{objective['requested_metric']}`.", + f"Metric source: `{metric_source(config)}`.", + f"Metric extraction order: `{', '.join(objective['metric_extraction_order'])}`.", f"Candidate budget: `{candidate_budget}`.", f"Config: `{args.autofl_yaml}`.", f"Fixed budget: `{json.dumps(config.get('budget', {}).get('fixed_training_budget', {}), sort_keys=True)}`.", @@ -1245,7 +1311,7 @@ def write_report(path: Path, config: Dict[str, Any], records: List[RunRecord], a ] ) if best: - lines.append(f"Best retained run: `{best.name}` with `{args.metric}={best.score:.6f}`.") + lines.append(f"Best retained run: `{best.name}` with `{objective['optimization_metric']}={best.score:.6f}`.") else: lines.append("No scored run was retained.") path.write_text("\n".join(lines) + "\n", encoding="utf-8") @@ -1333,7 +1399,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int: print(output, file=sys.stderr) return rc - config = read_yaml(autofl_yaml) + config = apply_metric_contract(read_yaml(autofl_yaml), args.metric, schema) + write_yaml(autofl_yaml, config) + metrics = metric_extraction_order(config, args.metric) + metric_label = optimization_metric(config, args.metric) help_text = job_help(args.python, job, cwd) fixed_args = build_fixed_args(config, help_text, schema) base_args = build_base_args(args, help_text, schema) @@ -1351,7 +1420,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: output_root=output_root, timeout=timeout, simulator_no_progress_timeout=simulator_no_progress_timeout, - metric=args.metric, + metrics=metrics, config=config, ) records.append(baseline_record) @@ -1367,7 +1436,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: plateau_min_delta=args.plateau_min_delta, hard_crash_threshold=args.hard_crash_threshold, ) - write_progress(progress, records, args.mode) + write_progress(progress, records, args.mode, metric_label) write_report(report, config, records, args) if baseline_record.status == INFRASTRUCTURE_RETRY: print( @@ -1380,7 +1449,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return 75 if baseline_record.score is None: write_report(report, config, records, args) - print(f"Baseline run did not produce metric '{args.metric}'. See {baseline_record.artifacts}", file=sys.stderr) + print( + f"Baseline run did not produce metrics '{', '.join(metrics)}'. See {baseline_record.artifacts}", + file=sys.stderr, + ) return 1 try: @@ -1396,7 +1468,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: output_root=output_root, timeout=timeout, simulator_no_progress_timeout=simulator_no_progress_timeout, - metric=args.metric, + metrics=metrics, config=config, ) records.append(record) @@ -1413,7 +1485,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: plateau_min_delta=args.plateau_min_delta, hard_crash_threshold=args.hard_crash_threshold, ) - write_progress(progress, records, args.mode) + write_progress(progress, records, args.mode, metric_label) write_report(report, config, records, args) if record.status == INFRASTRUCTURE_RETRY: print( @@ -1449,7 +1521,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: hard_crash_threshold=args.hard_crash_threshold, manual_stop=True, ) - write_progress(progress, records, args.mode) + write_progress(progress, records, args.mode, metric_label) write_report(report, config, records, args) print( json.dumps( diff --git a/tests/unit_test/app_common/autofl/job_importer_test.py b/tests/unit_test/app_common/autofl/job_importer_test.py index 67fdf1fdc0..ee2a47dc13 100644 --- a/tests/unit_test/app_common/autofl/job_importer_test.py +++ b/tests/unit_test/app_common/autofl/job_importer_test.py @@ -23,6 +23,17 @@ ) +def _objective(metric, source="user_request"): + return { + "metric": metric, + "requested_metric": metric, + "optimization_metric": metric, + "metric_extraction_order": [metric], + "mode": "max", + "source": source, + } + + def _write_recipe_job(root): (root / "model.py").write_text( """ @@ -102,7 +113,7 @@ def test_import_recipe_job_extracts_trust_contract_without_executing_code(tmp_pa assert config["job"]["surface"] == "recipe" assert config["job"]["recipe"] == "FedAvgRecipe" assert config["job"]["train_script"] == "client.py" - assert config["objective"] == {"metric": "AUC", "mode": "max", "source": "user_request"} + assert config["objective"] == _objective("AUC") assert config["budget"]["max_candidates"] == 8 assert config["budget"]["fixed_training_budget"] == { "num_rounds": 5, @@ -262,7 +273,7 @@ def main(): config = import_job_to_autofl_config(str(job_path), workspace_root=str(tmp_path)) - assert config["objective"] == {"metric": "accuracy", "mode": "max", "source": "default"} + assert config["objective"] == _objective("accuracy", source="default") assert config["budget"]["fixed_training_budget"] == {"min_clients": 2, "num_clients": 2} assert { "field": "budget.fixed_training_budget.num_rounds", @@ -311,7 +322,7 @@ def main(): config = import_job_to_autofl_config(str(job_path), workspace_root=str(tmp_path)) - assert config["objective"] == {"metric": "accuracy", "mode": "max", "source": "default"} + assert config["objective"] == _objective("accuracy", source="default") assert config["budget"]["fixed_training_budget"] == {"min_clients": 2, "num_clients": 2} assert { "field": "budget.fixed_training_budget.num_rounds", diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index 7efa6d2a20..7339375949 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -70,7 +70,35 @@ def test_runner_prefers_explicit_test_accuracy_alias(tmp_path): encoding="utf-8", ) - assert runner.extract_score(tmp_path, "accuracy") == 0.8 + assert runner.extract_score(tmp_path, ["test_accuracy", "accuracy"]) == 0.8 + + +def test_runner_applies_schema_metric_contract(): + runner = _load_runner() + config = { + "objective": { + "metric": "accuracy", + "requested_metric": "accuracy", + "optimization_metric": "accuracy", + "metric_extraction_order": ["accuracy"], + } + } + schema = { + "objective": { + "requested_metric": "accuracy", + "optimization_metric": "test_accuracy", + "metric_extraction_order": ["test_accuracy", "accuracy"], + "metric_source": "held-out CIFAR-10 test set", + } + } + + updated = runner.apply_metric_contract(config, "accuracy", schema) + + assert updated["objective"]["metric"] == "accuracy" + assert updated["objective"]["requested_metric"] == "accuracy" + assert updated["objective"]["optimization_metric"] == "test_accuracy" + assert updated["objective"]["metric_extraction_order"] == ["test_accuracy", "accuracy"] + assert updated["objective"]["metric_source"] == "held-out CIFAR-10 test set" def test_profile_budget_suppresses_duplicate_imported_fixed_budget_args(): From 4c1706ff0856225df1f32b9668d9d40099ba4945 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Fri, 26 Jun 2026 14:36:50 -0400 Subject: [PATCH 12/23] Lower Auto-FL literature watchdog default --- research/auto-fl-research/README.md | 2 +- research/auto-fl-research/scripts/campaign_guard.py | 2 +- research/auto-fl-research/scripts/plateau_watchdog.py | 2 +- skills/nvflare-autofl/scripts/campaign_guard.py | 2 +- skills/nvflare-autofl/scripts/run_job_campaign.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/research/auto-fl-research/README.md b/research/auto-fl-research/README.md index 738a6d2a11..b240fbb254 100644 --- a/research/auto-fl-research/README.md +++ b/research/auto-fl-research/README.md @@ -54,7 +54,7 @@ QWBE is currently implemented as an **instruction and artifact workflow**, not a The current flow is: -1. Run `scripts/plateau_watchdog.py results.tsv` after finalizing each batch. Its default hard trigger is 32 scored non-crash candidates without a material improvement or literature reset. Treat this as the normal trigger for literature mode. +1. Run `scripts/plateau_watchdog.py results.tsv` after finalizing each batch. Its default hard trigger is 8 scored non-crash candidates without a material improvement or literature reset. Treat this as the normal trigger for literature mode. 2. Start a literature-review timer with `scripts/log_literature_review.py --start`, then generate source-backed proposal cards from recent `results.tsv` symptoms and relevant papers. 3. Filter out duplicates, known null/worse ideas, and proposals that violate the current contract. 4. Score each remaining proposal from 1-5 on expected gain, contract safety, simplicity, evidence, novelty, and runtime cost. diff --git a/research/auto-fl-research/scripts/campaign_guard.py b/research/auto-fl-research/scripts/campaign_guard.py index 0dcf42279d..bc0344a9ce 100755 --- a/research/auto-fl-research/scripts/campaign_guard.py +++ b/research/auto-fl-research/scripts/campaign_guard.py @@ -33,7 +33,7 @@ from pathlib import Path from typing import Any -DEFAULT_MAX_SCORED_SINCE_RESET = 32 +DEFAULT_MAX_SCORED_SINCE_RESET = 8 DEFAULT_MIN_DELTA = 0.0005 DEFAULT_STATE_PATH = ".autoresearch/campaign_state.json" DEFAULT_STOP_FILES = ("STOP_AUTOFL", ".autoresearch/STOP_AUTOFL", ".nvflare/autofl/STOP") diff --git a/research/auto-fl-research/scripts/plateau_watchdog.py b/research/auto-fl-research/scripts/plateau_watchdog.py index af9f15327b..09ae364ac6 100644 --- a/research/auto-fl-research/scripts/plateau_watchdog.py +++ b/research/auto-fl-research/scripts/plateau_watchdog.py @@ -25,7 +25,7 @@ from dataclasses import dataclass from pathlib import Path -DEFAULT_MAX_SCORED_SINCE_RESET = 32 +DEFAULT_MAX_SCORED_SINCE_RESET = 8 DEFAULT_MIN_DELTA = 0.0005 NON_PLATEAU_STATUSES = {"crash", "literature"} ASSIGNMENT_PATTERN = re.compile(r"\b([A-Za-z][A-Za-z0-9_+-]*)=([^\s,;\]\)]+)") diff --git a/skills/nvflare-autofl/scripts/campaign_guard.py b/skills/nvflare-autofl/scripts/campaign_guard.py index cc10650898..374eaf7449 100644 --- a/skills/nvflare-autofl/scripts/campaign_guard.py +++ b/skills/nvflare-autofl/scripts/campaign_guard.py @@ -34,7 +34,7 @@ DEFAULT_HARD_CRASH_THRESHOLD = 6 DEFAULT_MIN_DELTA = 0.0005 -DEFAULT_PLATEAU_THRESHOLD = 32 +DEFAULT_PLATEAU_THRESHOLD = 8 DEFAULT_STATE_PATH = ".nvflare/autofl/campaign_state.json" DEFAULT_STOP_FILES = ("STOP_AUTOFL", ".nvflare/autofl/STOP") COMPARABLE_STATUSES = {"candidate", "keep", "discard", "crash"} diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index f87c4d45ff..7f66e5c383 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -84,7 +84,7 @@ DEFAULT_SIMULATOR_NO_PROGRESS_TIMEOUT = 240 DEFAULT_HARD_CRASH_THRESHOLD = 6 DEFAULT_PLATEAU_MIN_DELTA = 0.0005 -DEFAULT_PLATEAU_THRESHOLD = 32 +DEFAULT_PLATEAU_THRESHOLD = 8 FIXED_BUDGET_TO_CLI = { "num_clients": "n_clients", From 1480e1bd0f728443249e2958054b8155e1458730 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Mon, 29 Jun 2026 10:49:45 -0400 Subject: [PATCH 13/23] Fix uncapped Auto-FL campaign stopping Signed-off-by: Holger Roth --- .../scripts/campaign_guard.py | 13 ++++--- skills/nvflare-autofl/SKILL.md | 18 ++++----- .../nvflare-autofl/scripts/campaign_guard.py | 6 +-- .../scripts/run_job_campaign.py | 37 ++++++++++--------- tests/unit_test/research/__init__.py | 13 +++++++ .../research/autofl_campaign_guard_test.py | 19 +++++++++- .../tool/autofl_skill_campaign_guard_test.py | 19 ++++++++++ .../tool/autofl_skill_runner_test.py | 21 +++++++++++ 8 files changed, 108 insertions(+), 38 deletions(-) create mode 100644 tests/unit_test/research/__init__.py diff --git a/research/auto-fl-research/scripts/campaign_guard.py b/research/auto-fl-research/scripts/campaign_guard.py index bc0344a9ce..1bee21bc17 100755 --- a/research/auto-fl-research/scripts/campaign_guard.py +++ b/research/auto-fl-research/scripts/campaign_guard.py @@ -26,7 +26,6 @@ import csv import json import math -import os import subprocess import sys from datetime import datetime, timezone @@ -88,12 +87,12 @@ def pending_candidates(rows: list[dict[str, str]]) -> list[dict[str, str]]: return [row for row in rows if normalize_status(row) == "candidate"] -def best_score(rows: list[dict[str, str]]) -> float | None: +def best_score(rows: list[dict[str, str]], mode: str) -> float | None: scores = [parse_score(row.get("score", "")) for row in rows] scores = [score for score in scores if score is not None] if not scores: return None - return max(scores) + return min(scores) if mode == "min" else max(scores) def parse_max_candidates(value: str | None) -> int | None: @@ -169,8 +168,7 @@ def guard_state(args) -> dict[str, Any]: attempts = comparable_attempts(rows) pending = pending_candidates(rows) cap = args.max_candidates - if cap is None: - cap = parse_max_candidates(os.environ.get("AUTOFL_MAX_CANDIDATES")) + cap_source = "explicit" if cap is not None else "uncapped" stop_files = existing_stop_files(args.stop_file) watchdog = run_watchdog(results_path, args.plateau_threshold, args.min_delta) @@ -221,10 +219,11 @@ def guard_state(args) -> dict[str, Any]: "next_action": next_action, "final_response_allowed": final_response_allowed, "candidate_cap": cap, + "candidate_cap_source": cap_source, "candidate_attempts": len(attempts), "pending_candidates": len(pending), "scored_attempts": len(scored_attempts(rows)), - "best_score": best_score(rows), + "best_score": best_score(rows, args.mode), "stop_files": stop_files, "watchdog": watchdog, "agent_instruction": instruction, @@ -245,6 +244,7 @@ def print_text(state: dict[str, Any]) -> None: "next_action", "final_response_allowed", "candidate_cap", + "candidate_cap_source", "candidate_attempts", "pending_candidates", "scored_attempts", @@ -268,6 +268,7 @@ def main() -> int: parser.add_argument("--plateau-threshold", type=int, default=DEFAULT_MAX_SCORED_SINCE_RESET) parser.add_argument("--min-delta", type=float, default=DEFAULT_MIN_DELTA) parser.add_argument("--hard-crash-threshold", type=int, default=6) + parser.add_argument("--mode", choices=["max", "min"], default="max") parser.add_argument("--format", choices=["text", "json"], default="text") args = parser.parse_args() diff --git a/skills/nvflare-autofl/SKILL.md b/skills/nvflare-autofl/SKILL.md index 8c30cde888..32401eef5b 100644 --- a/skills/nvflare-autofl/SKILL.md +++ b/skills/nvflare-autofl/SKILL.md @@ -177,12 +177,12 @@ Do not invent a replacement campaign or new objective after a recoverable failure. Keep the current campaign identity and artifacts coherent unless the human explicitly requests a new campaign. -If the user provides an `N`-candidate budget, count up to `N` comparable -candidate attempts after the baseline. Do not count deterministic import, -validation, smoke runs, plotting, reporting, the baseline, or -infrastructure-only retries caused by sandbox/socket/runtime setup. Count a real -candidate crash once the candidate run starts under the intended execution -environment. +If the user provides an `N`-candidate budget, pass it only through the runner's +explicit `--max-candidates` argument and count up to `N` comparable attempts +after baseline. Never infer a cap from an inherited environment variable. Do +not count import, validation, smoke runs, plotting, reporting, baseline, or +infrastructure-only retries. Count a real candidate crash after execution +starts. State must report `candidate_cap_source=explicit` or `uncapped`. Treat plateau as a decision checkpoint, not an automatic stop. Summarize the plateau in the running report, refresh `progress.png`, run the campaign guard or @@ -194,6 +194,6 @@ next mode, and continue unless the state reports `final_response_allowed=true`. Only produce a final answer for a campaign when the code-owned campaign state reports `final_response_allowed=true`, for example because the user manually stopped it, an explicit cap is exhausted, production policy blocks execution, or -a hard safety/runtime blocker prevents further comparable runs. At that point, -end with finalized `results.tsv`, refreshed `progress.png`, and a concise report covering -baseline, best score, metric source, failures, friction, commands, and absolute artifact paths. +a hard safety/runtime blocker prevents further comparable runs. Then finalize +`results.tsv`, `progress.png`, and a concise report with baseline, best score, +metric source, failures, friction, commands, and absolute artifact paths. diff --git a/skills/nvflare-autofl/scripts/campaign_guard.py b/skills/nvflare-autofl/scripts/campaign_guard.py index 374eaf7449..9206158123 100644 --- a/skills/nvflare-autofl/scripts/campaign_guard.py +++ b/skills/nvflare-autofl/scripts/campaign_guard.py @@ -27,7 +27,6 @@ import csv import json import math -import os from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -215,8 +214,7 @@ def guard_state_for_rows( attempts = comparable_attempts(rows) pending = pending_candidates(rows) cap = max_candidates - if cap is None: - cap = parse_max_candidates(os.environ.get("AUTOFL_MAX_CANDIDATES")) + cap_source = "explicit" if cap is not None else "uncapped" stop_file_hits = existing_stop_files(stop_files or list(DEFAULT_STOP_FILES)) plateau = plateau_status(rows, plateau_threshold, min_delta, mode) @@ -270,6 +268,7 @@ def guard_state_for_rows( "next_action": next_action, "final_response_allowed": final_response_allowed, "candidate_cap": cap, + "candidate_cap_source": cap_source, "candidate_attempts": len(attempts), "pending_candidates": len(pending), "scored_attempts": len(scored_attempts_with_index(rows)), @@ -316,6 +315,7 @@ def print_text(state: Dict[str, Any]) -> None: "next_action", "final_response_allowed", "candidate_cap", + "candidate_cap_source", "candidate_attempts", "pending_candidates", "scored_attempts", diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index 7f66e5c383..3a16112e6c 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -455,22 +455,14 @@ def run_allow_timeout( simulator_stall_roots: Sequence[Path] = (), simulator_no_progress_timeout: int = DEFAULT_SIMULATOR_NO_PROGRESS_TIMEOUT, ) -> Tuple[int, str, float]: - try: - return run( - argv, - cwd, - timeout, - log_path, - simulator_stall_roots=simulator_stall_roots, - simulator_no_progress_timeout=simulator_no_progress_timeout, - ) - except subprocess.TimeoutExpired as e: - output = e.stdout or "" - if isinstance(output, bytes): - output = output.decode("utf-8", errors="replace") - log_path.parent.mkdir(parents=True, exist_ok=True) - log_path.write_text(output + f"\nTIMEOUT after {timeout}s\n", encoding="utf-8") - return 124, output, float(timeout) + return run( + argv, + cwd, + timeout, + log_path, + simulator_stall_roots=simulator_stall_roots, + simulator_no_progress_timeout=simulator_no_progress_timeout, + ) def read_yaml(path: Path) -> Dict[str, Any]: @@ -1339,7 +1331,15 @@ def campaign_summary( "candidate_attempts": candidate_attempts(records), } if state_payload: - for key in ["decision", "reason", "next_action", "final_response_allowed", "agent_instruction"]: + for key in [ + "decision", + "reason", + "next_action", + "final_response_allowed", + "candidate_cap", + "candidate_cap_source", + "agent_instruction", + ]: if key in state_payload: payload[key] = state_payload[key] return payload @@ -1532,7 +1532,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int: ) return 130 - write_report(report, config, records, args) state_payload = write_state( state, results, @@ -1544,6 +1543,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int: plateau_min_delta=args.plateau_min_delta, hard_crash_threshold=args.hard_crash_threshold, ) + write_progress(progress, records, args.mode, metric_label) + write_report(report, config, records, args) print( json.dumps( campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), diff --git a/tests/unit_test/research/__init__.py b/tests/unit_test/research/__init__.py new file mode 100644 index 0000000000..4fc25d0d3c --- /dev/null +++ b/tests/unit_test/research/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unit_test/research/autofl_campaign_guard_test.py b/tests/unit_test/research/autofl_campaign_guard_test.py index 4d0ce71a0d..cc200d3d8b 100644 --- a/tests/unit_test/research/autofl_campaign_guard_test.py +++ b/tests/unit_test/research/autofl_campaign_guard_test.py @@ -97,7 +97,7 @@ def test_campaign_guard_allows_final_report_after_explicit_cap(tmp_path): assert payload["final_response_allowed"] is True -def test_campaign_guard_ignores_malformed_env_cap(tmp_path): +def test_campaign_guard_ignores_ambient_env_cap(tmp_path): _write_results( tmp_path / "results.tsv", [ @@ -106,9 +106,24 @@ def test_campaign_guard_ignores_malformed_env_cap(tmp_path): ], ) - payload = _run_guard(tmp_path, env={"AUTOFL_MAX_CANDIDATES": "not-a-number"}) + payload = _run_guard(tmp_path, env={"AUTOFL_MAX_CANDIDATES": "1"}) assert payload["decision"] == "continue" assert payload["reason"] == "continue" assert payload["candidate_cap"] is None + assert payload["candidate_cap_source"] == "uncapped" assert payload["final_response_allowed"] is False + + +def test_campaign_guard_reports_best_score_for_minimization(tmp_path): + _write_results( + tmp_path / "results.tsv", + [ + "abc\t0.84\t10\t--name baseline\tkeep\tjob.py\tbaseline\t/tmp/baseline", + "def\t0.42\t20\t--name candidate\tkeep\tjob.py\tcandidate row\t/tmp/candidate", + ], + ) + + payload = _run_guard(tmp_path, "--mode", "min") + + assert payload["best_score"] == 0.42 diff --git a/tests/unit_test/tool/autofl_skill_campaign_guard_test.py b/tests/unit_test/tool/autofl_skill_campaign_guard_test.py index 4ce2557eed..ec3daafaef 100644 --- a/tests/unit_test/tool/autofl_skill_campaign_guard_test.py +++ b/tests/unit_test/tool/autofl_skill_campaign_guard_test.py @@ -79,6 +79,7 @@ def test_guard_continues_uncapped_before_plateau(): assert state["next_action"] == "launch_next_candidate" assert state["final_response_allowed"] is False assert state["candidate_cap"] is None + assert state["candidate_cap_source"] == "uncapped" assert state["candidate_attempts"] == 2 assert state["best_score"] == 0.851 @@ -125,6 +126,24 @@ def test_guard_counts_candidate_with_baseline_in_description(): assert state["candidate_attempts"] == 1 assert state["decision"] == "stop" assert state["reason"] == "candidate_cap_exhausted" + assert state["candidate_cap_source"] == "explicit" + + +def test_guard_ignores_ambient_candidate_cap(monkeypatch): + guard = _load_guard() + monkeypatch.setenv("AUTOFL_MAX_CANDIDATES", "1") + rows = [ + _row("baseline", "baseline", "0.85"), + _row("discard", "candidate_1", "0.84"), + ] + + state = guard.guard_state_for_rows(rows) + + assert state["decision"] == "continue" + assert state["reason"] == "continue" + assert state["candidate_cap"] is None + assert state["candidate_cap_source"] == "uncapped" + assert state["final_response_allowed"] is False def test_guard_cli_writes_campaign_state_json(tmp_path): diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index 7339375949..99d1f92211 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -286,6 +286,27 @@ def test_runner_state_finalizes_after_explicit_candidate_cap(tmp_path): assert state["reason"] == "candidate_cap_exhausted" assert state["next_action"] == "final_report" assert state["final_response_allowed"] is True + assert state["candidate_cap_source"] == "explicit" + + +def test_runner_state_ignores_ambient_candidate_cap(tmp_path, monkeypatch): + runner = _load_runner() + monkeypatch.setenv("AUTOFL_MAX_CANDIDATES", "1") + records = [ + runner.RunRecord("baseline", "baseline", 0.85, 1.0, "none", "baseline", "python job.py", "/tmp/baseline"), + runner.RunRecord("discard", "candidate_1", 0.84, 1.0, "none", "candidate", "python job.py", "/tmp/c1"), + ] + results_path = tmp_path / "results.tsv" + state_path = tmp_path / "state.json" + runner.write_results(results_path, records) + + state = runner.write_state(state_path, results_path, records, None) + + assert state["decision"] == "continue" + assert state["reason"] == "continue" + assert state["candidate_cap"] is None + assert state["candidate_cap_source"] == "uncapped" + assert state["final_response_allowed"] is False def test_runner_state_marks_infrastructure_retry_non_final(tmp_path): From 1782ba51c2a5109224f76e30198d22e8ec7f05de Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Mon, 29 Jun 2026 11:21:36 -0400 Subject: [PATCH 14/23] Add Auto-FL campaign progress visual Signed-off-by: Holger Roth --- docs/resources/autofl_skill_progress.png | Bin 0 -> 267673 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/resources/autofl_skill_progress.png diff --git a/docs/resources/autofl_skill_progress.png b/docs/resources/autofl_skill_progress.png new file mode 100644 index 0000000000000000000000000000000000000000..2ec9be10238751a72f55e918684aecaa6bf4c2d0 GIT binary patch literal 267673 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⁣>{p-aR|M~wzwl~S2)_j6Z6CD=mgi3;y|8qtD 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�u5VY8fu~P`gnGk(X@$`qEZxMx}N~v3?;G^Uuf-fGy~jq$!fwV0p^4C6HgdyEgiDT z*O4>`k&_>`h@kKB#$kkcLL5LnNfTWrHALnC;n3uVUK4N1@@r()l~o&ph|KurMZyb1 zG>m6j*Ya8xj$4*R-Sk5G45b>MaffrCN`>_d^o04KnC{sBt#U=^+X@OKQuQ9B_5s6l z13%8@dgJRka|aLp13A_nwiolI_-W0wNpFlsH?DE-Z|?l-jPp z?|j(w9&h(!fF3nP)bP%YdhX+*C5H;-GffxXZd?<8wQ*TgLe8A{aM}q;!CMU`fNfh2 zj^zxIT!k`M+j2y=lGA&-5}WNRZW$lAY1bgJ#Rr=|Fm;k4-a{lhzUCwzm>9C~e5itn zgi!*qL4nvLt_GYep5@!D|*@`3`-I)P9PN2Iv^@e?6B#n0m_L1AN)R2ws1kDl0^n)DJ7RDKiOM9QHvazphE#Ii{+v*4_;Z29tMdQ@Eepu4kK+j(PwYyIgdgB|~0 zQAG~*>RfXt=cRdwym6)q>?HNbI`Mj)vBcweyAWEDDIO+fE@o6c_2}joFRmi1_xT_Y zA|!U;uUdbbA;}VqsMH5ZoA`o3or@)!ubh||h(L+ef9eZOm7jdOObj=d!$5tHM+ma<9Zmc{qa93fhYr_GK|@65As=?vH( zUFwmyof145ck||*`Jv%(qlJJLj+Cu2r@U&bEG$v$^%&(ej)dowyBl-~cHVlm2pvCd zv|OUuoHQTj_VR4ZCwjw%k7Ju9q9Ir&@#^y-D=KLjPWiG|uzc$2p2Gs8WwViU~3CxDp2C^JxAumFY5-FZcN^ zDVk@AQ8ey8zt{{q^{W^({2P=gh-eo;Ym%EYKGCtQHa=dwr}JUoq~TF!K6H$+>66;b zKE8iw*#`L*C0C!gYE4cHR%nwa>QCs-3_cG@;?b5KxxV#*@ zxoDb*&Sr*%pqt0>@K^n}AKvq?55I~%Gyy8Ui01io4;$>jxwOpobcRmm{`hP>%O8-XQU+wZ>&KS%o|9^Koj{fzI zwAeSuYlOCrQ$OVML=!|_-w7AEH27Pz2yKVuU#(o-tokBO+-0~tbpzp0^u36+!NMYa z`Xb~PAjHzZ?LqXg64-d2q3n~*z;J-bkvbG&rB+C^a9SrOX+sx}BPa+U5UD`H(q4tz zX44Qm107*15w?ll=LRGmB&-X>`DE-CLBVBY`k;-CAc^Y*u2S<{%k$l>oZowM=hP-d z_0*qzpBbS=rtmx5m%(QXU&m$^E~=Z$-;(G*+_~b;l{byg!x6P?wMqhan?dD$!?|rD zdm5U)5g3&2-(XNVT$;?2aF`$nE4RZMuJjs$^(c7HUeh$XfP{V^NXOwMj~ciS^x!GH z2iQsaJkj;MEj_WmtMr5;l?U}}ob(BpEGS~fj5A{3}ZhA1usz`V)T{W1@wZilo zqy9P6h9oou9@kTW5}8?9mSCQV0kPpX^o-;M$*7yw#r8(`M$3h}KODaV`ZrOreY9W& zU4K0}-TKUGdY6U#t-`_4Z?vfoc>==E`*Izsz(yj%dF~Vku*;A}7Hyr1nC<_4p_ava zQ$iAf%Q#n-LyB92AzZ|X0})3*%2OqvZ1LB6s}|=P`9q}3ql zV+_VjMlLCO02XS4lA&Y=@1H6Vc?igGY7K@gPnBMYq&GJ>nHu=7huyh@+rZNDIpuKYZP zyVXI(o1%1^B_uX$ytyn&K=}&C^GiInSqj-9yC1w2usZMEWaafWAe-Oibbs7*xX+JgO-Ra&}JRS>`JZ~;tV3veAruv3)|TUR=fJ&dxPfGR2Bq^qH8h|)*G+7 z(}-1Z-|DOcb?V&Sru^B{clqz1b^fxhf8eW+NXfgGXAkZ>Pj!_nr)7!Rs&#kOkDSk! zN-h2Vd%Vm_xpnu`?01e`#ak%At4wSY(5O+AfNyQ3=8H;SB2th@I$#9+Tw%Sm*;JZmsGlVH`JLgXA zC_`0r10G+y*7VEIb2!?`u-m>qUu3rR0Eppwf|yBNTuNf)L4~_>YlQ;P%tqk$V@S>i zx>3pe@g5Xf#P5$8re}sb4!v4jYPrIM5VI?$a~-ta+B5Lg+*v`TBV9*0t!Ybj+f$)z zvx=;q+fByJiR-hm-#Kx3hF^8g z^5Vfw@;vLCxDQV?=J|El9y}OYMxe@e5d1V0)aSr`fjtBLC$Ry#Qfe=eyagzxoxY>x z*)w9oBuqo-9`uG|@PS)k(VzpU1IWB2?Z>jj*`1WX=jYa;Q676P_uU*Q;Ibw8#0kHb z`6ViH)=O*1WM2ILG+bK{*~`d$8C8O?m4p+aqwPiaMcfh(Mj%?H5PwKqve`zrSyb#n z-`}I_fyxhm&jXv>74qePpolgMH6+0?gQp0t8?e8xnM;4E_N!>HzD-<9_xjMfpT@fg z5D`h)%JQ!SbKYC*%GRIY{UK8tDLuF*q?#p96rP84Tm~-tM#Kyt)+!wfkpS4h>&AL& zmW;fA#DQx@{7)|-zoAs4y9~pZ)dHh~bmb}vY%Bw!bs)MeY)pPYxd>#A{b?O$V36eH z2X}z_QIsIG5(l!&YTYV`pl}MNre(XigSi?*CKZT_b=>uqorTt%7{Q3i$Kg=*65S55 z1OXo*=~yVJjjx$~!*W)Fo`y^UfCZ)>rUJ4%LiZ+iOE)~X9as@5|4Bx7oi&;*LN<_~ zCX6=x<#!ENkO9TfUVYAd{-S6Y9*oLVRBnOTL?=quLfbTAcs0j~U07d3yJ;Ea!NUi8 zPoA2|_E)izW4I=AQ&G&RKW5JKzh?o-g|GXWHo$kG zv!ZBHF=w8GN-DXhy?tGA|eMZ3kVC61+_+S+v@Waxo6t+WvP#RP+V?HH zY8Y$KA6gliC%rv&dP_z2(dtBOXA8Tq*>J6o+lBrU?{=@dT6%-2HO-MJkU@hzo?kui z>XB#)<-@!_-#x2&VZ;03b$xvua`azWd^!ZyYv`-+7y96fJZ%{+N=k2Fw$oi7{wUrz zL(z0!NrQf?tJ;B_AdQKqQH9z5>ril`7k{~_$D0QDG_pBIM;dKK6jxuHB*N`3AgGlTfQYZ6R~$Q`vkhHX z0Ko)j!7}2NgmRyT@@H<2h620Z|;Uv79G`$;$YG2R@g1F!6^9JWL&__$KZ1QZU8j**SvTm z>AvAFxlU6qhAADF|}|8C`S~81fp=R+ir@O2ny&P;ucz}N23H;F{%7XZ2)=*E)T)1 zH?{;s#T6qVA7d3L#2#aoF2w4?^fFUZmrbvDP$jTYlB4E-{?%k~+T>(mF1Fl#_z9Qf zDLC?0W6$ziQaSr|t0?9f^?%O@SEvOOT{nT5SKAjgDcFFCp@JC6Ve#z+w8%vRExi^b zZAA~>q38#$)qrOLE}GYinF-aXTbVxfgY%*gQ=QCQiZO5*!~ihCji%LX>o7|k#hCFi zT)GSG0fp<5F-{qhaYJ%Y;2;Q{z~)F|RtY7FC6rjQh|xkI^3PwTz5-aoGq7m`*J7kV zwX_%g6;9%16bLXm>dqkagqz^Z0O$H3IVS`zmR2OvGVoklEX>bw%6mwWP#H=!$r2)u zH3DAAtW)gn&chws#C@{AXe_!(0E>zPUBggDP9T54&{V;G5$1&(V`Ko%so;cXx{!j0 z-x4x*0&t@=@F%MuMG1!dFcZ-v>Rmm7IimMQZ9)9078E_K5pqJ{_NIY~C-0m1(CX^y z%yACmBh{ddgtpPfU>1fD0^EJ+ED}nPKEa@ws6AK1p=Okr- z*e-A!kZG~FeHe+pnn)%&d#s;d*s)WdWk?7#vVL#&840=gj@IFj%qCss$YYe{w!68L z&Ic)3!*mZMU1|EGaPTMDsxj%&q>z2xFR$3WY=00bHPmU4M&%pUJZ}Cd@wVK+)F;IZ zm){zqVkay7(s*d>ABsCam8yzaTwo+}@ck&2vtYPW|G;D-NNzT^>sEuh8gJ zxL@u{JF|O#$e-Mm+@>3DittD7{lAMm z1#Sn9U=~6bpyId{SxF@IZMQ(M#$$W2|CyXLV&;f6TAIdtOqj8f zRrL17m~zt|y*Eq5c#@vShGaD9_RUwVp!mQd5phJ~;#UXT)k4#+Cw(5>5ALl~p$f9? z-mk*rxs+lj%lk6@A7_f7{#ZbY{ozvWdtEU%o^JI?PU6VVandvj57dx_YVALwubTtC z->>{}%G6v<`CCJnIf|&mT9ndKA6!qL_5qMem@+(-v4&Z&QWMVv@$2J?l2OD&gh5&_0JE>hDw-Ck`zKlsqeIMe~x8h{E3bP8)zW~rxMh?-U zY#QaP3a|f`q2bp#e&8#KdxnhIMTty0CzQd&DPwsiKb;(80BaW0K9M5Ri(pHf?~Y3= z;~#B&s;RAF{rH3CDo>#@D2l8i|2~R3#Foit&>o%P&yTrBlt!6#kIq_8B)@N2q+(@6 zHvGk~#l-Y{9yWSJZR{v|$qgjo>OsMMcZ?a+(hCLXX-sL`L}Jt0f`3z5*8Jhyg##*ES$9AZDiA5#(yp@c2WZlV)f%UWT>ZhF#TqAnrOx{r&hK?6|d#Ij~{bS9b^j1kFeO}(}l2t!S5_!X}Sg-o8=Z0D@z`v6H zM8DQ(*xC|j^&`v4^Q69WS5A=n#KGu7Z;$r7jMio}BLiL?*IHu#_Zos{{v_UNZEJhY zm&LkvQ2{RUVyRN(5z6ADy*BAyr(E;#@0+e?o2v*iQ>-gUYRr-|hK9p}ah!l%{&(-Cv7re{MR9 z{Lh6Sx~Q>U3k-=1Na#mS+Z+$8^!xc!2VFi{By;AxH*=# z)#asmI;zA(YB!gW_Be0$ zQmJpJ{>amldhqY3!AipD_{*SVMhQ!SBNNQiKV*z9J>Cxn{#5r#V8?lWi$Y0yzoBH#wTr%gGaqG}uXkST8HBwg;F;4PGpieG{`+l&iDv+K`n}N`Wm9z&3A3&y{ z@87%cuSWF??cmF%1(fh4Kn6eMDiO|@ULdk!cV4#jh5L{B9KUp!&f08m?~WsdwKKUp z-_W5$i;z3*b5P`{FK>Wb+4*EKF@-&Jxnjr4X*ph%IE^tH3g~~oir^H|L`!Odjtizx)Ak0#! zsAgGK5iRfa)je&-wd6oSoyv<(#Nj4KZF~5%^%+mlJKBt&7I1g`ShY1K9;hl3(fHAbVPawpdSfzh1hWEw z%UD9ums$88VlkqX*#F@ZX_-lL?fdEs>9dJE2UT4^&~pOq8=-5nFZ)@DdZCUx=z`3HS$aH-?_x9pQdy zI>Ka_9%&U3$3@get?6_`*0jBHx;B0M0QA5ubTm2xYuQo;h60c3e&SF%d20C)Z6Qee zdwZN#l80Zeb=VaCeO14H6^Z}5L+(7TuvCVGFlV{H`8X=*EMFEl` zjKijbDT1VoW*@x|sT%||V8-MATpW3S&Wmc@Q9brCIpKpR-!&z>8v^=UXr-CuM6v@O zh}T8{>xHf z)qRKs;y=fqeUW6_!ba3_BuQND)Mr3P*^yE)ad8?(M(dC+vjd{ePgmBTJe}wHH79FF z{J}jFFZY!r@;q!1XDvvl?1q_*d(KCo{QdL-q2{(1xq^a%EFd02``ef^Yp}2XRNn7H z0V_kFS?p^SKKy>a$quoHyK~C+G2bzCauL?wm|!?RE(y<653>nz*mM+*p@dKdU{}HWu}^Xk?o| zvobv&{_#M}*^cq!oTY%Y=$QvH4Ep*7RHM%E89rKmZ~Bn&6Qj(kD+N{@M;b;OBoAR#h;%i|_gRD(&@#-jAL?lVXq#_UIVU=1aItYwWj7 zHAClxXu{5H>6rd1YuyMl-<)^uE%(nZNw*qx>t*!||DdbZ)su0~eq+V;u-4VTdOshz z|MOe)`VCK3-_x=k@T>T-YKO-r13s0<>%1n;tYiQF;5~EamdjQ=PqWJNTXJ66>Evxp zR&Us46Q^9fe>CL9z}D`_i-rD^sUx;?LceLI9U8d$JTJHXY6#;A)(?1?^kg`Mxz^Rn zDd^LtIqiU0&4yd2)vdD3m%aZG!<0Juxo7+go9e!)|P@7;oe2k&A2pp7$oW7*gzQlM#+n=1=uTq1l z%~mel=g?+p{kFEU`Tgx8+H$VJB7#0M_7d6O28dV^P7a`kQUMd_$wWHk;gP6**7G$$j6HJ%+9J*(oL=?{i|-?lGKm6Lr5BuIDuS z+Z~rOrb{)yx9t0US7(u<3!a?cl>|O9tW;B!-@Kb0FBhd&=gxbW&v@gxhAg$)JNo8R zCb-|ElZFQN*zBt%ar@)--RORP10f6wl~`CZ2( zesQGhPfn|!U-L$i`_A3tlFv0ZI&B#Enrp`bbItO%(YJHi*RGX-0MSW${R@TPqL4~& z!x56S$2|&@Q3J`e+&`FkJOCm*yW|=0b6p)BFyh1ks1;~XSvlq6gMTHeCliF3l5s83 z&2?z&&%?Unj!4V%1Ci=*BDs!$`Ta#g@Bp|k3>X?3(ktN~cZP4@)?sI7Z%@=XBleAv zXHOguQ9Wbhpmv8$z$;J-M?i(zC=GuA1pxF845z}`_sx(@_^q6ubp%JMxiEjxilz!9 z7&sZ1kq`ni_d)g$H32LtsQh99&AGPgA1T{#(enK%WfMCoVAClVXWjwtNWn-=3OE6WJd8Ef3zhzwyYz6kx5379U0cM&Ze?fF-)=V(&&tjY zfJQ;)gkHr*K*TFnt|UYUY9SJAEmrl%W&eQ#W!P1drnZQP^x+YVduCH@W&}(k-51>f z=-AEk4G%y1+YM3m-DYoxt6AP-y(wwe%2oU&v}D1aE3RaFU72jr!b90ZHuHfNvW$T> zUv8_nzTIlEB}n<#lWtb#p~pjWGbxNlCQ4L&)sngS&lU@E567fDuk)PxAcV3aBhSqr zXFFE(ZW*1hkSw&;*UyFW4o($)FES-{|S46?CHPknq21JW~G#8x}0 zckZ8)wSIRxNkf#=KE)J)#-xk+Iffy5RnEL(jpjdvaif- z5uLv5xx{>;T~F6=(C{k`vN@G$N4o1EM>Q&=25|iBNCE;JoT6I02&KnD-<*&cJ?@m!D&fnAMo? zkdj$^0v^2VbP|c(zf#)qSKt+=cf4U>Z~2+U|Av@0}?rBO_5u zFUd&D*5~Sn=#9K!oL4(11(k-oeRrdjlo;`TR9qQ&omEB;!FqMNAhi zuCB?fiFpQFfvMBZXP=BsPG<7Ccj_6(raK0~t#)-z(t0?(usQklpL+U}y{|mY=Xsj* zyR(;bbE#_+U)r%zN7_oaFc?0Smg1tTuV+wE#_z?;>RUNXS(t}H&!w^WlyayR{QhnE zw91x_qMPg&_-*ai9J_itweNQXZXCPWf7W|k>stP)PUolk2Gra8?ZaG4sD9xorE~oD zio#u%V+?#;_|lC2e8a{QM>xICEUllVnGy+>Zz$>iZ7c0ZtF}?TKXrJd;QVE~LtGLH ztM6(&rk-qYD0Mck*Ub7A5@g_a@xiXgqeH#Tr!Qy6$SofH(f>I3)A_Vcz7Vw37K_QW z5eYgZTy^Y9o5RjD;q%M6IQNuot>iNkyT8Rd-z`GjKx*af_7+gvGmu-5ayut4Zvb-= ztY8ugI|0C_8ma1WsFh@s!*1Vw7=#1ipx_#uG5ts(Y_KT!@mf#2-7^n=j4@DJ7%T@J zMA#sFY!t6QV~r3NCHM50W9VfwN;xC=U2L_=X>crq43pgGJ;6eLCq_=RyiOm=^AfA`sC({z>)P793?}~gpB@oLLEyUuU$%J{mPyl z(SSz@9i0pUhsn!C)_e$K+>Yq;YD$QMoD!k3zYR^@JOx=VdnjhE8N~;Da z2e_uS+tUa9*!}y8ZNLcIL6q8!RlncA4fL;!y!MI89M2Y_9Z^v5m@Qr3p=r8&to;CG z`s33Q6M0YGWg9Dm_N}S49xZ=P&$mar;g+4&s|A0%c+~D!I2G;)QK~Ad66I9aiSd@LTU`^zza}`|Jr0r7&Rl-LLg9}_A3o8o$$cmr^~`2T&)r9( zeYh1aP9G6|0-Fx;z*%-4n;+*OYIh1r-Et`LnjOl5I?@N^jE;^@kKLo(T!v(=M)o5n zHKaX&37WKB#gqSYTQ=A+H--9r3|@xA__kAYqCT*cgJ4q!CT4ywMc>G1MYaAB(|Wu{ zk{I4p`n;Qcjk-~#D-*(2(!j7~eif64yCRI?-0E+|)SAgoiv%E?Weiz5_QM!o9n& z!+Z2W|0?t@b?8yH1C~(L)Lelx2BRA<;TcSUbL4kkqc@`dfSg*vpK~oXmeyP%9v*co z)L`&;B|~FsA)Pn`O&HmEkCr--{P>++xw*ON+1X87_?n}ktsuP`47md!A+u1G!A+xH zpQ9+m5)%`nOQrgEI-Px#3=J@`04mH3tO2K|hE$`VbQnCFAQ>PPK7QmGhmUXGT!$wD zg{k%rWLRUX^{Fv8hu4C12T0$2VgDPcimf=X$;-e4uVHPySuG&o6TXq+sy%h4Kb68vR2*12nG*TgEJJkzwR0<3pTXH!gE z^n$ZRch;=aPHr_-=5FI+;CsHh-e~YbXzv-&8E-*A(`j*;`!HXi=hKi++}iY za0OS0)-O-GxLgtZBJGfc4lUGUkrKN`YH{%`1&MzDth4W-bc*L!kVpeoi8w* z`26AoSEyRC@tdmZJ>C6h1aG(RS5>7$e4g0HjZA?blfPZI6A9I&IZ%>gm^<>KKsKWz z)BerDL7T>6KZD@VT-InyhShpy>oQVl?o`^}b_wh_RFXrD)sKux3`}p0hqHBus!U=(;fA6xrMLm?xwC@^3wH;Q28H5v2XWhE8gEcDRa5?NVr3h zP-!d&_4q_v+wZPb6P(>2qK~iSGocze^i{tQqWL26@KCblpV#wy45}V=XG&*0@+X~Z z=-P~Fzdz~4Zq9CADOATqLf-TQihjuRDzXBQr?lIMFK${$Fb0RG-jkf((-v;xWxlcoKp88(& z-z6~mU6cBm`FdBB!dn*&I#Fk{+uPQ)m?|htz6dt{n5Yw~ruTVPGCHq1Ws{8*m!s2> zH{(}iu1i0URT=2-ckTGy6mxd|oz1?TRvS?=q#k=L;kQ2F;O6EdeadfM9&`xR>eo2` z`s+v=lR-@4-Vz~a&G8t!Rd*-)gHLMpr6)%3;R&|0rs~|@O7n2qKfyCEU`>^^c<`ZHB z)m~lDk!mcpit@6S3$GXTKb`#Hfco#fx?$bQ0kLSK;!_Q3tGgdj(#$V1O|9DD(tPaU z&-TZToD!LdjeYFt(&MrMDo-~3^WDphYsbE{S4pZkeJS0%DA%r-YgYS9U2AC87LBc& zZ-zS7+udAUx@x?M0dM}ZxT>yB@#mC+!$aA3Dm2RK&<~q#BMUH7|(*DQMl&uF2 zT5a5NGd-K3>hvjoIl4C*5^U)T#uZn5G>)P2ifb8ZE1?AxLuHHS9{y8XF1$V4;g8Z~ z=98^ocP?!#I}+1rQxfTx9nm-9QvPSc-;KU**V~GmFPE8jmPsnyZh3lsW?`OdT ziX$s<1g6xHtQ{F__b5x$cUix3S21zDy4hbw9iU*Ihb@O{Rtxu&Vh<+y#`{?xLz5H4 zCHPcI06lPzD@2GfMyvVXr@W{#*d&SN1} zN2(omZHmfpEQ*&KwAHyWmnQ6MF_*Agx%JgwSJatD?}W(OXm(2S-r)4zzlP{9+d1SO zsy}S4T9Wft$F%En-IQpQT0@(o*Yd`~YT;vIm5=1y8-ttPMsMY4ynGy_Y z{i%>j+wC)Nj-L)`tJ?0J&+%UDKsD7y@ZnM6Wv6E1Hme-a)wlWd^6ME&)Ro3EsRq}u zwduk;Ts%${%l>|Svx~9>MWLRqQ{QxlsCy_&I?Dq|g?kNt{_1zi0k>{+QJ)Sn zt?!rhTl;axDfRL8cv}AQEwcJu+2RbX##rqOPj|fy22RZ-DayxnQ+&37IeqM(h)63Z z;|E#RS(OJR_Xrw-ZZJAGJKG#;Dl#JuCl)N&Xok;#6fWDhq}oXunEZFdjMkwR>@`$? z7L;+>5{wabdBoJZgQ@Jr(Lz9k#9a?`pG=U1%h<)fDk_SVNYit3A&@5H#~yk;t*uqY zY|Iv@&0h{-a7wEmU`Oc|XW)#c$h0ZL3`)~5_49GG#uW|DM+1IF;a_~yg@whY`m?oa zoXpM#j2ts!{E@)eH5ELek*8cNeK+id>47kd zo!ur5MV}u`UG!35O<(cgr>ubgk3IjyHB4e2_OhwQ4{ALT*}9bmi3TplAC8}vx};kv zRn4gMCS;e*YV1ic^!TIdR!Qy*w-6>E%{=P zAI5VYXjXD7Y1JXq1kwJ0W@-H&)q_XEXN&8)<-4WpJ%yF)6^D5`pw-891m zjXeFy=RVN%fa-AGLbDbAHP7!A*TJbA*4G~_zKii41eLvc^QNops6hB0WmsA_(cGrf zWuv>JC2_BA=8!HMFjnEIZn19}`q`r5W9=cw>=k!#VV?r3jT%A~Oa+b(+0xLVwIX`2 z0%V66jeYy)HryXR9AQvf6Jsw{lG8=itY%HW{Bm8f|2cYxmkR8v`u?R%=3{Htf7u~( zrP>bLhK-iflUt}B-j3cFG4f!fESx^oHFf=~obrRm?PKm%hnC%92tIJ)Y}KB{73uQX zO^v?ST|!#bHELdH@$7jZ0yt%z=g*{A{bR%J4#`!DVIyn<9<;F&H3s+||3-ZS0~a9J zg~-RJz%@*U5Q}q?^rwh~gcgX%j0!ANi-gSEty`{b;S6mMl>dZlrb(r~rc3tG|F~Pa z(fdOj)QAlL+E*|!Wx|nzj=Evc8@`CAxTcM&9Hzt{Ve;WS&^l4$;s7+vFn#Yw1irAS z=l~oaRxkyS^-ji(_xHDDH?k`Se2OR)SRULwf8+DZdqZJ^L25U?oLSdhrRgjxIsJ7r z&4Hw%n=E4}T~Cef@Mk`hCUZM!2db7$0 zpaK74?dV(gGd{Yn9%_v|dCJnIO>WC{6>%SUc7Zd)I#EI{6~qU0P~YDzc!q}!>bYF& z@3Fl(St$;q%*M$-=M<{IO2+RvPK&Rx0m_BE|JuyCr#6y6|925;0X(dO4nGw**hjCQ zOh-)J*q8a$6o4;JhS<+}d_a?2Feo9r@mV>fldo&pNV9EwRo(q?$B$C6XB)0C|37rS z2RzVg8#j(hQc9T_m6nw>5GqQd2+5WeDYFR4ND2)?k-b8ZJ+n%vtYjs7g=`Wc68_)2 zbI$vo=l?wK=W~Y6N%;Nlab4eaN!?$2*5c>BZ*wNLHuwIy4am1I>Wd@)`wO&-OW-S> zHk!@D3WNzRsdld*Vj*cj@y6(AEZVXkJYjTN<%PzS&&U3Askua2sek}t z-$duBF}h!Xc`aNOnEw(0w0-ZZu}tw=BN#VX*WG1n=n;NKr%{{m^rCs1uS$%&4^ey#VT;FjmN@K(ocV-*6?85NFD);v~DT@~bB!bNC+7%m}fOc`BHW znz%o}5U4zUu?6#xti>;DY+bEUlaaIV`{V4v0|!X#81P`^abcMKmzFC~AX~KO(1Gnr z&FiIwI$-4W2A#qU_G2w2Ktn&cQm#OpL7mn_JF;8D?ZVo~09wSbWnc&!RNJxCBg2_ME`agSM0MZ>AsWe-Hh07J`7}1Jr}GjXJH?5UG@H5@t{d9 z{0hgbg?hjjQOUP$s~q{xaE^g2@v$|HJVa+B@vURsp;QGSHBV~*xz9406D^rrM9(E+l*q%d8Kb2S;U2D z<@->fKK$A8n)(AMJifL4>V7w-`Q#4749a7tHE!>J^DwW7d*g%SdvkY?3o|L{Z7kA% zHk=*JcAL?H97iO~yK3*NheQf~AcRP%pr^P5A2YD|S7j0~>~>g}bMEBkZh%V;5b`|K zI3!IY4`*5aK&UxIx^~LLt&)*QY60z80{KDxuq`Ild!E0NU;2Em!}MLF4YGd8ka-fO zg?w`Is$|*W4_Il?`~Cq87Qui?k^6H}Q5mR*QkH?-_0A0bRyn7`%XKJ*c>MqIx)1c3gfj{F$O_wYjb19 z?$G;G+7V^e?Mx^dQ}jl&!3!qu=1-){rncmCqV3_LHL;`Rq4i|cWl%VC`SYfwh1JFp z^)CmDK6TKlzs`572`ckdn*w1dK2NV&zLhVbh$J+r{AmE8t~y`jA%?ht^CFu#fQxbHx&!J?3eJ1F8zcx0V;S9_qTW1GhoM z4&FcaFXB3Xj5K;-8;Qq-@e2s_Cnlw0+F4)nWPSs7IwO_?r0XT1G>*fQ-0;Jh0$#Ao z7=%K|KG-d{z^+kVKo}{Aj|k}&#E=eiP4H1C5v8LuPa39B!v@VRAJo`#FDs(2wRaKj zglaGy6PmNRMT_15$x%|wy90wIYm)*y9j~+pWN-AdXDg9^SX{VZ1ro!q?_hhMN@6eQ3FX{X;5PWmPN*Imodt17r^8D8coK5NwwTSQV;(vO& zNV|6J@aF{o9k)koAqUXiLCL>+cNs6&N)j4w@=_ZC(wtRIsk&>6@$`mIg;Onjw!ywQ zg4Z;u)n^k=XJ3;b0?Yo2v49xt1h2$4dB(&<&%{I|3TEOO&&p8|KzbV{kLAB!Y|Z;6 zn#YgXT)gwNU-0^SaZ`Ma3%hb{r#|J-j3YAl$W{}x?sYf%Rao|BL#-NOhOm^n9e{xA zj;u)i0B{xqzd>YWUF(+vpU&2w7wPiw=Th*deA)zEHHk|%PVNg!uw~ z{P_26SV9;-_B3w(QCL5}5WK@&X=Bq~`jaSV|1M;O{9ZgW@JX)R_S$I^+xXU~L(__k zDt@|(XOs1hI(apn+M7Y^%LWq|oA+HLogsit#@YF|)=w>@p`x^cxcDB#fnq5HU+pf6=QUnRbMzEC*ISKSEJO&tk9QIFH7~~Dg$Pqp zD|Jgey>0&;x6+GtV>Y|PO7A9a4>&LF4?siRzgq7>U7U!$XiUKVfH1KP;kB;MZg;Ud z|G3;)u5+P;yQ1RO@|(c*Zn0o|<6>TT#jL2gi3x$ zUr}rMjrOI-d%dk8mca2$EqxjitvdkLm>dT(uL8)%^dd z%fKca0Fy^F-eta9Yat=QqgU`AM_|Eed3h4<>LYjdmSPibDtcsP9vKX#UdKv7M#_AI zN2-2wTD=$%W1C+mCQg9|jkH-f`%}zOTcz_}YC+FkcFY~p@a`wP2^wv(X0ciP zo;h{VGI*h8&Ujm24DF6HLpx{c@6fKSJ(^i@wX#t0Ee>^}c_ zGq?l?F$mEZFc3*gOi{1Gg`7WuMi>DG4HeoB)h-UJ-Y-+> z^kph-ywb6alUlZq>8w+!ESD{{0M{BypZ3%t@d7$s+2-HMw^0n?o|U$xz1DHXqQu(8 z_SK%~`gW!mVeG-K-%VDvt9H7x|F8O&b>2XbLiYIM5v|M)uD=kX9%~o{AgFkZ%`=JF zL6f+oh^<1@l7Wp)4u=dSMm916WFz^vujdECd@NCpmcp=0ZmC+{EwS|#P!Hwxdh(Nz zLr{dRE-o*>hj!&5p5~kScB{c~vI@{Uyu;5vrpe60B8#>LI(7jx5QkyJOi@likeDxs z!aV0&p9e!OM_xdgi)4u?Ox9a5TW*731)s0cHp<;%j(#ybbZ<5w;6MW=%o3m?I&#Hi zLJQgxEA1ROfw8+*l4$RmVTs>kS+&G0iHpW=rvWF#I@q!l7!DVq{nJL+#d?X%Bk-4sBl%83dDKY*xp?@9WI#<$i%A?=dB#%p^ zB`w0*ivL~%pQd>-J6a0li*SpJx0yQppN_*8?s%Q@!v19VML+6~B^;F+22oR4Z;Yt+mg;v~v;SiC3aq-MlA)@duBE2hnEyGU>&5h`O?T)A z&8k@Lozk-Z@Z54iX-K-XUsJ2;hJiYOAk&~9PRac z$2$GUN z@wGkS%pej@bX3WE1pdg6gzf_^>cKFpFZ_k@!Vb$X-i}5Yq`QJrZecJve*4BHsG%dWgMM^}?D% z`F<*WdlOuDp+*WXS5+%D%i+FeI>W};Kh}_|r7$baA6vA=#$(mV*rz`~Md!AmF^Dx` zzeOv`HG<eCvm-(|V&zp*?SnDf)_!5#-k20fQ+(F%EG zIv2PsHP%x^{F;CIGtl>xl+LM-Ow5YkvVA8-3g7Co_04n3*Ex;m{eJASC4^0b=E7jj z9AnJ$$1Z#RlMy^=^QT5|fP{jum3OKW4YLxz7%IqzDV(&C$yB!!ID_h0J-%etNh*k5 zVc@6B!_m&*AG%^Y*L&N^y`h57U{|6IO~~qLc1cC+)0G6l`Zti*W-`C=KqM}O|JZDLXsE=$8wq0B#~b| z-_iT58Yc@QleeLgvsmoWkO)no{1KOAp%?5bk@{Mr1)0C)p0TghFtua5W9ZD8>p{=L zoT7Nb?z z>EIb#3cvE?mwVnH-ZIkjqSQkQaE88#4Zb*w$QVBG-ERxEo=hD|HkUgn}rJxy(!TnDru?v)2FP}#Q6vRQo5Bo+c*lyzpSR#$S6Q zfv&BYyTxmEMa)sg5EHtO0nCcywJVBW1#NIlD_gwJ7)oFFVtfr3TN$?WXNd(?-(lvF zPpoNt2UP;~aTMHgYa;(MVzQN9cdumweycLO(oaZ_{{!Rvp6xENaV9kgrE^6rU+8C| zWF3mWS2sGt@yGmAcj*BJr3aykn?$cRg}+7Q*U?!6?tx{p`sUBha>?a+rSd7{4Pt^| z1xoGCD}Bx+ItxyDy$KNw9qUyH%bC||7r*&Sh=}tf7k@B7R7dU;`)?nCV~2=Z$Lx%{ zKkY>;l5`X%i4{6yZ$PiAc&utnyhBBOm6J`)E&tPN}* z$dBi~YOQPD@6;cyW@@oeF!1Bz%hPOIWQjyhok-*igxf2QYOgzXG5(8q`CLe*FJtEA z;r*%3zhkIV7^H+A9`4IZ{P=NT)#}O?e|h7K2a@sMl$`B%%IM$hTg@-c*)a@hRqemk zkZ(B?6ElsJ`BrO0J&UPR?PHr7+I;i=1k<;3=l{kO6h;7={Xsr47WMlVw+$ll9Fo7k zuYlo%_!+9N{l1&2o$|LkCHpnR_vrp?%wnT4(TkK+=<}Cr5+d$qYVPrQqY?xp;MHe! z=!eB_{>pC1gd`OSa3nvcb68^;Ty3TOtH4-RzH5Z}q8o;ue!2JPQmi#=7D^wD0eV zE~SsQtF~?MdN0|*zEyu~{7ea5@U^pldxI(wcYYr}MQhk>vIxNd81<5Tu{Qc@#P5^P zB`u$BO#onZ!=cw%S(upe!+$geV|6MZ@-bx;=15|Y%pcDIY08go0%2gj2=qmNeah67 z7XjBjNPW4CcsGKcqN^6UG$(cAAt@#+41`<37P7uJm$(J(h=rf)uFzoAgLJdJLQLM~ zyqd$WY+Co(Z-u3UOSERIcauZWi=C%kH>U2tF={gHHd-os8y5M46GpmiIijz^W7!o|ZWKK98>79xSJ6)U zs&Kn#&MtpS(x+s-Ib+Mpp#T^#M*Nz)7!&uZ*8|`Aa$WR{UVK`w#;4P$w60;w%xy+1~6 z`woX;hAoYnS_q=Fsun&6+|Y=q!}ORD&Fcew!66})_!&$wJpopPFQ725hd1LV&&OMP z;O+e!{`}VvHe8AFr;m)Q=h0M9S8a6oAc8we%i`QG)o>Oxnr}+VaKUAtOH4c$s6Smu zcT7ufSE53ST?BoMvDYy3hxb4C8h?J+mA#d*8XwyI3j1ERPe!6{t>AKKL*yE+P#N3i zvmn>65CZwHk~y2IUiXA(hCmW7#=(s!B17N9qb!Cz%UcWW-B9~NuIdL6jCqeHL1rH6 zrImMgM3ke5=7sue*RC}`v$~~D zMCT~CS1G={vUhvqGWCLyZL_Yu*b?3B`+Oh@=@zXANV?n)$3i1r=r3V1R8>=JWNUBL z+`|k2S^mZiQ6vf3CDBo2wSFY0J(^}k47vm;VxOGk!U54lHRu-16@FpV3ARB70&LPY z6aM>B*5-8Xm({`h-C#ob_Ig?|w{O#1rpD7M22lsw0zSCFI;ygZxDz(N1T3hwN+o0! zP(i~{+vE?U}UZ% z_qTiBL0>4nyraHx51MS`nsgE7Te!{{;i@heby>Q>dV$C)l>b9kG4j4?k7rgDg&R2Iz`@cxFRnwi9i>+t6_uf9YEI=@TBT4=_DMTJ1(*Lh2rHTQv!pO;G|`neHT~ zY!(PE6`>5Ogo?*=NLTbG;4u1F#2h6 zOp!e`vH_S1FHd?A4=EoQ85u>FfI}hNf;$f@ZgOghOe`WXM;9+$$^>mk=mX5ox&gL7 z+-DKC2B@ZsiIhI5W}=pK@8CD8aYTl1dZhy#`OhFxHg6s9*Tnn>we?004jrR*lx-ns z&DGQN6d;r*WgG5WqKb#X-Fh&>J`-053YQ!B@GPSTc#!&(=}j@mRnxUqlbIVOa-Xhc zJXunuq& z6mL0jdmd%W%(K!wqkheo`LR%~qL?ZhtrahPknCOwP{jN*@cSFXy;8 z1efI6b7iVj|J(#2%Z2?~l}FAV8@NpsRGz0EA1#{H@$T^&E*;J&r#F&ivscfxEGf_~ z1V$a3Na=%W_WB-0yFF=3S)sb@gHl4w_Ij7y=_9%StszZ_AThU1CiZ4QG{%XrY;PWgJ=PJ-HPU7-J@bP^H;|MQgVBv;IUWYC792LbQYWA<{3CT^u zLfruWEsD*2xY;?X5EMF(0sKW05ySsb=(6M8d0z)qaEYH9(owX{9Fx0V`n z`H8WXk{U?21)rykbrL5Rn^%l-@lurhr|Vx0QnSxKQkFX|W9mIat$#Fn4du%*UiX9E z>LrKkhVsTe6Av8B=zNzqUwwhF4yIRjNtDRMh_CMB+?KW=mA#WK<<8X?L47&=QVdi) z)k)8wX>vW)>{80(ZX&8tJ_v91)tLME_&}w*u$jt5-9)N2e06N3L0Qum_ZJCSRu5RbC#}%fP75k9s z!B)<3__{^P8ch_X$CVl5hewe0wz}9dw^QBxcVhz^Jk9%oL8F2gJ<>ci6font6ky3h=Loc_;FrbA|I~sbLZY7 zFiqF2im6fU1u6kqaMGP-BwtW7MIF~EN!SKQyc0^J3Za>i5Ucz5xE=jvIF`x~Fi#1?OUpm8}Buo@5Bs)!N^kGP}< zt}CvNLd9KhL-asB8_1<>cKUw2tR3bDcxw1AfB%}g^N073i#O1?UHo1T%agO)YywZ} zb|Eg;lUYp-)+T#gF6b$yDJ}JMWsh%dY>B3=9|1?4wVs8(i|a*`VAAM$Zuj`S249c= z-!?u>`$_)$n$*JJQ#0bRY3AL$@_$*@zvqn%Q!LNd-XccjANE6aT-aWSd9}B=g>x;Y zVVL8z2C6M$2tvu&op3%3ciy zkArN&Ai5}sw60)knE21>nVFP~GqLreoC_9%M*CPtysJC^yre(=<&%7Zg1#V1WzTzf zc;G!?3$uN`bdxAl8_n!q{bFj#dV&c>(0^S-W+(ZMinj*5tohofG95J0=xcOy)GRFU z-2H(|!tLo=W)opEDGV{YvQ$r3>ebu6UO}hIh-_9ixP{Jzt0^{ZT=+36+hqJXcwmq5 z;_xM(v-g`u%Wo~-&Hwc2i{0OtCYSUHOFgUmAKm8Z^tiPJQKB6&1Aa`DVd>oG8p-K) z1iP97?20wCPgBqs_(w+8;F<%iVVCH#st#T*3UH8Wv5t*+9OfJt=;;M`-y7)tVSx$Q zid~ID-xuJUyN3s#@zAQ9S9^6d zE(q!tJb|>YvEZuRxlj4$)B^C>k)+Wt)Y+4tsHjs_8^j`C@0cKR_A5(Y?-6A`-I}<0 z^!H(2uyfcyy*;}kcKA@Ve^rtA#^=_+5iX8QpMlDmj6R^CXJJ`gj5C+T@oPYFBsg{2j|q!Q|#Zu@lDc$hv60!tTmm-xpFs#rvoZV=#FUrGoGHI z8fjVku67+^nehI#2woYM+i+Bvf$))2Pb`3u2te0;*pG^zj}PoXzES8$$`#Z?a^X@! ztI#xPTL5v8WqMr@oRkc&BhYM3m!g{k`X@8ic(r<}b0hD=Gy(EHvlD7!=8~!E5V^N= zA3FC)PtG@EJpXItMTqBZENpg5E{z@vYTx+QFw@WJb#O;tspgqfwR2VCs;)80nm^_o znI-dY8w*~w`*k?)K=nIi*oBPA<*#~dAevnMP>%>*bpEF0_%Omt1!&utu46bp;=Fa3 z`FVP8O2pjV8PAMfnDfs1t`ib}BVJrn+Nj zR^zV5LUcjS{9H%iTO~7+gxKgd9Ql6Cdw6xyGE{feElCDyub_*BOuYxQwG2V_cGxy3 ztXwIo!9oO^;4P0L0*FLH!>Wv7p5%6A6|rFba)hZHM+=gj;t>k2I8MYu7-!%I!|J7) z@YCoE;BP~tyY#EM=-RLBn~o1K0geR8kOl#kOXT`O7ykgWpdLV>HWogSh62Q#Cv19%!=a(2H3VNy)bFV39wRZ`s?)An ztZjO7l1of11l4LJh8xA_5H~zleTvFbW4z-+mV)GsYT;(2XnuXtp#fJlTBj30t>C$S z_m>@;a73hWgWuNyNgnKszVy{{FO}F$gxkODncwYcm}VGHq1v6g)W8E}n4XKHkhX)< zw$NZ&^(@*%&m;#G*``Zh*hfld1nHjF+C8w{vbpx;A#Ur=qPu7P)D#CwsA|jh>}7>% zruE+13JIMrzf=P~rA(wKEEi7pvX6PlBw7Db5%R0xla{}bkm;9kIOt(#{sG+-dVw=1 z$_7iAOo7jEB(;{^X5wzm9n0&RDE1WM^GsyDuH?JhQ|G>9#eeC?=&Y|fi7W(i#_x>3 z!_b~3_Iir(oJ5MsuAVybe_e zH@RmaLimb$vD8E;rqL|qh{FLguHw&95`ZqAynI-ZgJEHiI&mTdlTh$l_G6M`VXXK$ zoE}BLr`AGdoBA>W=V}9TIP{#xg>j7Dsl2#-_iki7<=w_n!Qx6lT%6I<-RLt;GnUCWilzLsz#zuns#Z4UQ!QE}(Jd1k_~ZmSnc3zJrk^ zZF+Q_Hc$z`#3w0J^x_8T1@6UA&{b&hitEa}qEr@6ocpbI z&?u{wXYbxJ6Cv?HN`TYR4a47OQVL&3^Aj>SQ7c6q-2p^jWIS?X)0wY4f`Vz&zrXlO zAob6!NKe*=W;Gl+4h+_O<^IkGE z7-nwdO8VfeMNK>h_3jO911xM~BuP>@|LnA%o1f1Y3W$=46*KR+tY}FCfJxW(3#Hil&jmLP48DlvDpxn!B*rzG)&AOUle046?vKZ2sXx5T>~_To+lCeR#5a$Q z>e%NjD4}}GO3>sJXi4U~Ra4r;Za4Js9H)G?#hS(KTEE{@Q^h(D3GG=gcx%P;e5NtY zaJl;718{4J*x&D^B3C9F+ozqbJkC_!W9MCBYAoC7?#HwT7w{L;a%tzdp4$3LCrER& zAWFZp>3uy7*J~=euN(r~_lhOwpV;_M&FB8Yf2YBN={7LyMCeMfk98eU@D-48w-AR? z=-r%ALmE;*mU=Jx?WSXDYJ6S_psN97#ysUQ*x!!C3*MW2HEVmuS8PT)L>LPPWOpZK zhu5loUn6pDxZ6=)ZDsw?4?S%Nd-wes;>aB}{PEaVOG@WO+Xm0wH|?lGeJlI%xgH%u134dWHB-&7eu zX{{HAlN8cuVnXLWdXy6@4YTV>=ZRu7$>>!ZpT!z1D*Ol*>Q1Zpz10&Y2hv2;{g2jv z7)pcY?RY&8(ch$q@X3GV(FWJAP}SvY`Se`u_VYIjyiY&XT|Sk$p5>gLrU&vfly#-{ z-KaXR(<7eLD$|p2fWog zLTa{%jEPjXBdlF|&9>X(oOo7keon;hES-MtHPHF!C4A)%)ix?ZPy^e&|4=WB{x+w6 z^+w_OV*07y?+Pa;?IIU>MQdg_R{pW>MQj>;iIv+0=NkDNIDmEvbBg*K3kIp|?TL#D z>>!O^YEX)=1@|4>Eb8|%aatGswsr({SnFLG1jWQXyVLl4!8SN?bd9S>5?va_j&W*f zF@Y>CH6g!KJUSO4pftO>mttc^w8_lfOFpM)>VpYK`8#kGT%JMlMz3E#^=nu?6%m-g z$9Op)3&)0zt-PNsyofJNE9M@aTDN`s{`(Jg^LNtuo|xFBZ|WIX^4~{lkI(<4GUjun zboQ;IcJSLIm94m!vG7`4qK_#xlXvfnr^fnq=_F(&@b0XUiigM5sFq?T-*#Yro_) zbTcvEZ9|B*zET|R%~oHVN`1cvr`Acw+PHKH{i^WL=i0jycl`L~hC^$)LYvZ^91XJC z>J}vwex*D4bLK85r}qDIZb_dQF0_3%@$vbXH|=&ms>zw3*4j}-8A=l+KFgf)n0ys7 zbQVK$NXtX6*W3u;zz}x=&lFa+^&SPHN*r@2itDm2*AA>NHzL>@G(7vlsyiGD#v%yrXdgZ&6i z2!FEO?{2BEl0l4Gg%W@CqAQQzU3p1{UMX}etuy1w>r;H2O`dpPIA_U~V zJ~L^f(~;t<^k`$_z;J~-y?2ROhO(dK*1n~U63`F3HcouQt*{=-R2t7o1Q!jo1nzlr z(`-j`k?0e+tCj8wNGE*(tuWoUzd*k=rwn3tlQkVTBQ{Fi2>g%Zl1Ln%TF1Eu{3R1M zQO2l*b30CtR&qz}m~{r0uHa^pt%KR}63%#~(w-5t4$gCCPpPsWo1h zU;h}y8_}bd8up-%>PIpg_mLyvsKc@zY-j1Ein~*Kv6iZi{pDHNEdlu-?lgYI^;`2I zz>6KNuYqvF>{tHAmk33o*?PCL-A#D8^&P^tX5XZ>F&LG79OG9pr40(<-}7v|f^wVC z_LV8itqkvD#qXt0HmqPo+8j?=Z37nqNp9!Al^Xfg@OKQFUg-If2bNkoKWn2`mM`uv zb9!iAJEf760j_^tL5f$oA#==2D?4Y$)vT3b+R{4LG*#|$yUTYtkOvj{?*}#b;gT9} zC2x6Ew`*5YkEzJtss@1%DoRHiJGjFAiM0)(PD1vqTc#&&Er&r9?FVtC75r|^VXiL` zS4T#`fNmuN7r~5Dj;X!fTQIbpTtg&r70TLBK4&g%VTj#?Qg`K?+P}y8&Cm_&J=RCx zp6ZT}H-4}NK?cZ~h&?Dr6>Z`Sm#MXtacDzk+E!gz>GEysx@xv2Z9-lI;6d_3yii)v z-OgwnxUo&77>0_miTF+T-apFxrn-ZgTbh6H?cCUr13u4dEstORY?zRlY0&mTJU3$Q z+o_PqvqDGb*9rWmM$Cg9SWuQr*Otun%y$%eGG%{(@$@iL8NRM1^^VP7;zkM53+iLY zTt~N(V$diUt!@DMz>}aLe}DhJCoMvlkqtY;sKp>am|a*FM9n`oW&zd4aTqz#sa=0U zovochT%$1I8f3MS0TL(fuCorkYYyjSvR+{*e3f-X*H|t_h$I%lO91)Tafm)bFP@HB zztDdw8_`N>_ymF4MKf3eA`q6OBBOciY0Vr9l7Q85qtDx?AH=3JdTr9ngE|!tJimvT z=xxCrfng;u0g#)|!G>eV0Ne$r5_58T2kmD^=&^uZ^5pFnp%-5y|Y5X{{yKfsPEjj z1n>7h)?FGZ)D@fZD!ikHND=4hA#rhiwuvd#JO5{O|7CFBO2~G!=9DI=395XF=Gu^HNqd_RY`FPgd;k1q~odGDyp>gS^{36EVK~HMAiI^5^tA$2#BqM{k#+cw^z%+xFFd zZ}@|r^;lm~__^&L;8f^6M5~`B{WKlBE((fVyQZ>*LeGWzp$j#5zKyNCd15CR1s*z| z=h9BbSWlt|MrJiounk!Bw#lO{lVh#X;AcC4We{H_oYVl#2%(B6Piz!$u9Bk~2z9;L zOR~9$MGnuCjQvzk+693=)IFJ4(6E6Z#NIkv9{~ph?ZC4m_Va^2cR6YSi~(8BfI$I? zf7&UFL19%vg4}!e`XGRj#KmEwaa2)aAkVmH7v|5(J35LZ`O6sFlgINd{(KncR)7_; zkt;5MM+#>4x82>{rLv`Va6VA@e9b(FUA^8_CH~^bdah9aMhoO6#w5;~2_R3R|IAIU z(DM!#n7SqMAK!n!4f2Vq{hX@K6Eq?&j;>dNc75SAuo&Pe_24`FoL(j0BX7$EI&Bgb zYm!YHd+Eo8(y+sG6YtE&`g_#sh8U44cN+m4p@*fpv{N2$n7!uEj@)LW;`cS@)@TyZ zBPpEDf0*J&jzU3`9bB~oa^?)OJGI48LI|i!ORt3EoSFdB5HKjJ&=-(h0eop15q)9; z%kAAuGPo28N`&!sc6&6`0!R=ntd4H5C`J;Dd!OBiDkLrCc*G6>tvTB@xQ&l4&GpTc zO@2IBD>QTigME;iEleCU5LEaF2A1Ig5;~c*Xp+mnvvyF90&I+f=-bDAcRIHO>}9@S zBV+4-KR#s*swP=|kmYvy7N?KT7d~;KbapNDY>Aqmo1-|Zr}r(RZ*9FcY~*HH-ReT0 zgUzy2Z=UIA-lL{Yq?XF*+7`k5d*G#Eb-t+0S)UE@@+B3{TwUvn3gYmQ6D2Xl#j%X# zDe0sJdX9-&#KGFR?Az3PMkL7_QI!&zDE(^poZ8>V!68Nse!=-#j}&0E9wZYYU+`+k~bnyB4gy7$*c zB=t4uqL{9mh*Km~;)jMsO`4wn2rKRAVIM0+LW}gy!?in&U$*d^5iFIA8L<0a@F#v* znTaJCVGnqsYDV*!##KlJi_OgRBd)>Mi?i+I;35A;pmDK;vB67nDM1`U20+ALC!^Nj z*28J!RA?>q(^;qpbGit#en8nwp$femo9OMw_%@tfv`6%O1kbYOoZ3yL3Wm$D)x$6+GgG8eG z3fvu1*%$vygl#3;TgSRSRkYG4&F7qR0S5?-f5*z(<&z+s6G_RgGl18pAnbe;5O5M0 zJp8g^Bt<4E1u5mE-ho+~jMj#p0|RtJ0Lg#Wo?pEUlpeG@exQO?0PVr4LHL=WhWOh2 z@W45L7$?dZT^e!JklCF_z89~s`*vPa6ZCao-#dZaj<-NAYHaMWfDTesGtt|yxaqM|+LbJor6xRBI_Tca3R z&ma1HC2O$HFwFL1#Un1_QeVd@o?XMrb{+v56sD%8(z1s&wt>2|X_KL;&$du!f+bXa zBK%&;L6eM73#8nIc%IF^fz;1%3C&}*N#?0lhn1y1Rm{b0kkk3@vm987Q~q4&`P-!z zW525=G&uex9ooBGv{cO)P5)MxdMjr@>23-OW+s*V{E!|0I4E!=Rr6-5>~p&^@!_u0 zPa7)-q@r_K%C5g%|3+sr*m?CHGc&a{nh^RC=E7qwi@3loFx~mHZRW&h!ol=FCj*)) zG(Bf7UAp9LxBTRy^X1hLo*b@<~KN|wIQ z)sspwUlyJvlyoB%}(NR(BXxIG}H^nqCoL4}Hz5CQHtZ`y;Mc@lITfbCu^r2#+ry*(%p`%!46 z92zKthx{X0A5F|BV9~XG+)$u@Ymte}$QC-iM@pxTvCc`@LXPfabQj+V&w;T9R@(X( zjCGrZ!K|}fs$cnZvDhTX#V)vUVth9wlP$?2K$>2?kw+@kF201>5l!r{dL^Y#_ivPs z|1zP|e*K$HHL%5B==JZ2?yGFu?QDxO1*|I%EIy?d@WAh@ogAk$+Zh$b`^;Ms+i?%j zSWl|bR;J%SdYiO!>CCqtD`G$Fuv3pqn*ksQy+8;etw}`u|4mlzXk7Hnakrn|Wpj&5 zTg?|CdceTgbt;ZQ)mxXiDLvA8>NJ_Ih7*G%j}If0Y4v&zlF>?LgcCXe%~Zql99mjh z1Q1bSA!8&k)~Y={1H%byGq78{U@|Wwae=r2mAOoD>yjoN>N%J>ixm!aN)xhkcDxUJ z_N(`yF3d5B#1&QHZ~x8#V;0Wp=yaHj)4@_t+Bt9}(M_IbQ8jHB68dba1&DqasTE|* zE14<;E1{k0v#Ix!L|V1H^iz_g91+>!SY{Ok$&V~*2(vS?}-5%SWB&iNET-A z5@#uyT!;GpLfzBj&Qq1#3ZbsT=n+VMbeGG5CBlvO%7%x8gpi;IR1ql7!gexq?Y-!k z)XI0cZbTR)YCT&xzphsG?C@$jiIkD?+a{EZC#`=NYb@9vd$s}bgq3Mq$9lex*p^?5 zGT0F$*`_q0#Ijd~v2^nGPm4vtHOgr5jaKiY(gZI%H3L9hSdpUSK@6=e$UbMq( z2)du2_&v`9hYgN^Vzv=+tV`f*sF_@ zBPqmKVj`@eiTNluKrB(rAv`{UPsJO7L-K-!Pk_7xkua2&mMr~KS{V3)7YpJe3#z&J z67`4x!|@y-N)*Ai9KL?TX`_c>hGViz>thw-##@&2J0vA@T%7vrN`=#6Dni&dni1K>*MR|mF}aWh-pfK6Uq5HT>@=VB z!H|J1;^-uap-bjtOOa~atnv+E3JxcDw388qb6;Wo4y04z>~)rUh|oXTiAJ-oUe8OL znzO#~zF6LKQ)#c-uHEA6d?pPLY+6RoF<4%FTDPJltZJ#Bd-MFUw1M@f|0x{lrG)&E zSzm9gdZdKYd_Yt+O&*r8b&nqeCN}CsK%4(yhgg4Y1C68YCj2!17+|Lhil%mq=LuhYejQIthyk{Z=O)fD0M>ov$<{; zU^z#IbcoOX!!$a0ok|@S=yILluaOS(NmoiNMW$IK|7$3%blMEA3ef zHnu|Lc_puhI%rcm$%;m})IfH!c*e%3fQ@`s?s3JJxz=hNl;QOA3K;TnO^J&Y^!Sa7 zSzDB7tgj~~O3m2b@f>Y*%N8$vb7Ce{8-WdRTNtY`BS$rC1QS^FmZ&$~J7wr)6$N`5 zUlw;{n{%Vgt-X6r@&9d2G}(RG$J#Be>-qjIkgBwNOI6sVt$z0O1(}4Z_O)C~U;ZE7 zH=`{0GdAlg<&ylO?9i3Th;yLYXZHDpb~9*^Q0P80EZaqb+SoDjggAdumP3NR-|EY; zKWQUmQ$Zy49-~w^tCIY4Fb&AeGAliu-W7_T@jG(Gq44oEEPlUyS)C&cIDt%Tz3C$C zwq(>B>7foCVhO93!80E{I)F=6|LU$R$hyNJXP(CWO2nkds(S#@gZ)Gof+g~rZo&?8 z1Evo#Oy~rj0i)^XmN+gemV8e1GwpOAAT-W(O;yow%HSTrpp zwV^%ts;n|2xlG{6A3is;kIQ|n>OKvv+uiZ+ta}ziYhIk>RvEk>D80(7@{-cqPkBqx zeV^YPp)zuwq??U_si;h8!&zn3lTo9a9*i-LmAICAyjQ)l+jxfC@6hA_{ zk(Y3@N|B;&e9B#BE1ZmSEm=Q;0ay1>EBV$=)~+l2(rNbQ7j*Ui-4d|%4#hPvUJg_V zS1Ze?4hfeqT(%hQ?_YHcU8I&QEdbM$w9VXq=x0llklWLr^Q`;k{!St?OF1^vy}T3@ zcBS9}Ib;AX1hwhl_yIY_4^V-OB*3@_t6x7%p(T)lH6JPk+feaufcy?au*!$_`f~q= z0H2jx)YKes9)%8|hph?O=3 z%i-xM{uj%U=(6TIh82%nx;9=^D*nWl9ezUPWoD=)53PxLIj{^3dqRp~QRtql3@@YR|$aCcar zr?I|J?=*Uf!z_wQbz+y1&i%mm5tCYeY9${uex6k`ER$?w_m=7TdNqG+fy?_s!>f%= z62<;pYn9#pE7PN%entFF%JgCXciEh*w{NjKWq!`z`Cbl+Ozr9&NsVoSitQFT+ReZ! zDQv}?zisj=B}I`Ia82}@WKJ2@hpk3+KH8B<|<4dFOKv(L^pa4VN}G))Zvz`!6|4Qh0iK-ToEj zt2AD?|uD^RJU*rh3|O}|vl7XK^( zVRHw}KJ!4%UyTf!KkZL>0a6S}Oa&yx5&(fC5V7uUpKm`NOwoZ-?Oa z+4x5;^cK&M=X)lIzT7slw6&jPLJrXUne8EMRCQXQH&Kg4%rC8YiNBZ2{<`tECs9z| znaoV}ZTyG0QZUDqv!MfgdYjU<^VO%lrNLcNAnpHZ%4kxM##MU}q#{y%!Bw87oFSLg zcmUI#;>VYW@gGXLKr%D}wk#W<(Er37DmkvQ^$`~CdT$E8s-Nku@;U6Y0b zUGoRqi^Qawt2R;aRPx5hR7hWp-=^fjzjq%pYt1q*2)CZKoI1ky-b02&ohRe317#j2 z7vR>GI4&dG|KT97CtRn;{nDiU{h{^w&ju$n3~49Pjn4c89-&A_97wsX8$>qjuzgT{ z>Gjg(SJP8dTLqQPg_O~?w6qjB^!C0)Oy?2ULm;X^_*>WWn*@f#ouUmK^>|m>gXsXw z#c`VQN^)@Tx-FA>%bJI!yE6j%qbD#KQy{v?y0Wv_iXV#po6TkCobj!dfgY>(tNotrjBKuo+244u2P9WH{R9!`Js5b^KwK-2Td5a2cva*U-jf$eM{}N#4pT-Kdoa zm*51TDTYts{{2US5C6cEHD$0rzvtSoSVh9NTlNC>j7^IyHtCL|Rmk2XChH~oW|#k` z*K*Ex9=@Mh$DQN!qCrG&=`QGADIJA zst~PU$cAxi)p^c#ALpkmj%n)Cjq!g#QSy(_keHm9c~53Ry^omUb}uo}cC6(wHjDN5 zFUJwxLQ_nR9#Zc}03C(EKLaH?4A0**BZGouuU$KUQMNM5jIdWI!5oNR3;jMh4_eKlrf0N!YBY& z9Uw~DB_hJ?3X~dE6eQvn0~PKjomh?(Y~Bk2j$mvdI-e*^;7N=T%?kGkdVj!K+gIim&Zsx#5Jm_s&$A@jJ(mihE$;x zfR@CO#5#xGWTf-Z!Grz~Y$L)2CYc2L^Xc5XnHd@BIXS)4t}d#{sH*iN2@OV(ja#?+ z>ZRE)K1=5gFco5p-0o)$$*E?=*oTnqbc|r0a zug$T%4OCx6ZEc!eL|R&!?1G8SmUd!nzuGN9wPFRz@HG2Z@O#82@}cxr3^;rgh7=*T zQe~t(hJon@uk&I{=b6X%dQP2sEp`7cq8R7y z3LP;8Y4J#HVk~AzHN+@HJuP~0ZuZBk3>BlhJkm-1HFF%+x0E)V+~Mx@KbZS!1p*q4 zd>SDd_X9|)MO6=UOxV0Bxd*W|XCjZ}H;qeT)F}!4iiiGs*Y4erKomWKwGM$!?*W19 zlk9Iq_z`a}Tw19awLpc=AbbtlhyRbQuK>$(-M0Pd5|CCx5D<`70qHIUK}15NQ9!z+ zyBiS<xmdq@|HkkPr#!25F@G&Ts9r&)w(T>$CQ=-R>p)eBb-d7<0@qkce#z0QUUC z!Z!T1Ie-;La>p2T0ULxllgRlOmrWIr8wa`TanL(}9Xo+1oCr|F&tLH^dYBZNL_FBg zypf=bK0>Agerg+s2WfQR@&&w$VDGbI5&*0m96mGI7jOggXAGEYU{Fv$4B+5+RKr#} zjF0n5*AV~_a4xX7Y;9{p6r!LzdB^vH6k><-Q5^z1@ek`d4n5x!TTB%nCeVZMimbGY z{@jPPGI>}#03ZW68$jozht2H8H+3t~0F%sZZmI$Vc27mc6!asb$hJ0tnG64G5o&mk z50$GoHOmO0{eUtx2cJMQqOe_nI?6xOUL_RjteM|tY1x2!0z4B3*d*+vQS(=ddF&&W zw?ISw1KS&*Ijjsut6*J9Jthuys!H)4v-1g1QHrA0QWCBdfejW2Z>I3+_iPUO73lln z#=MS;LktCz*o3e5&?LISvs7S<;V z_xe;oNWPS>!?2?F%L8TQNF@r#k5{AQk(DPzhG=q0x-VOas)O$-%wOTn{)G=&>V4*p zj0~F%w_GoQei#!l8{x>fpK5dc!vzA=hgR@1i~|sg5omlbKig+DrC13DOy21 z1Fq<}@GwZFQHMqT^UAemQh|XP1ryOLzA&}{a)qL*su@hrbRE9k0B6!@fF18vPr%B| zU%(-O>5IG{kinkQ@OpoL&#I+JljxBK0Pa{yZz-ZQ;W7ggXAz&koreoM?U>^yqS&?w zpc|$xjR+wE!a*`q0iX(8Vc`8c!XH3%1s!R8kRnycEjXftP1*eRzd0Wz2a`K&tl-0o zg6=ac0`b3i?Zok(;%T`g;ku8wls^A!^@5w!SoH5Ql|w1BGdABk{D+HynYOjBUk}q< zKIprGhYlT&&FOa-(#R9ir3n;#kMAY^e9$pB!a3+zDwAtuf2pRdKskl`y`0Pm*af`! zA0iU>{t;OzI=3AEiNoVrScD#LE)EV&hA<2UUF8J8+e%$F^#L(N%3LTNZ8x^Kudu{@ zvzOR}|C@qC6TAat;m3pJodtj$+YEWZ%L%DUqgEawJqOZ+K}~9wl6e`?I))uwL^ta- zjDp}V!B84%A*7BLKUuqzyIu^M*|0l)AG!+Ac4mcS0N17uZakFP?<9O0#+DjjPywd* z^MChK#o^6D>LDp%HC|X+LX5;wuXYNa6$CD$?7%TVqIv!zd*mNcf^4nSrR&g5bVD z`wKy2l@vn%5;94GIeYpE_~StEqvRbBU^ZU2w)v`%|J=S*~9~; zxM9)Jiw!3mmY+>N3Q#D$g^``HV<|mBzc#y)GVS3JGmf|Tz2{-yvb|u{GVW!yFK8#J zbUDUQ#D3-us7^Hk4GUb}=d9>d_XtAf9%6v&6xaa^k_AiW~U&Y63E;+;W4PNPG5)vucXQ{Vd zTv~!nDM?T-2N0`t{Ml{$!!0A&3xu)eKPufta7!^UF@ODD1hf}%S9p9Ih!;qX267;5 za3H(AAmIR!Txi{_?6-y9!O}j+oq`S82f(i?G*uxA&q3w_LLq!lwap_81Y@3`pQrA+ zsQU!?7Ybxe5}=0{j+cO$VD#7^9>+`g+jGd#1Q7&8>cPOk0P#^hM8tgokv;-NyX2h1 zQ_vrO@*xEjw|5Xj7=N``%8$ltic#H_r$B|Yma z?%i-DExo!|JSO{BFY;8t9YJ2+9UD7EtMKg=)l|`Yz>5W{2CN*2#E*Sqr^s=LzIOjQ znD+tQY-E!rfU@9Q@DJmHJ%5u5n3PW0d%%9hOBPUleA=J#vC`iXYA zISE8FPXLmmONVkE&>;KIz@utL*0P}->vh-FdMq&s3Z7g!j5=_uVbLF;6(KGO@R+2G z2tt0Z5v8oEnhSb8aDaxERR_KJI62ooon18q3dt#?R1^62r;yyD9K@w{LxCHfK4__Z z++vH_aWn(2rU1&pa{D&k?ayw_QXQ3eUQB;PZ#P{1Kq1Zd82l@;g(&7z*jV^xVtT{> z+xNq&eHrTbM?=r;w#6n=a?psPcUYMxV>Z(!TH4fWyLiI>?W@W6zVJe8wWPu#U67BG{5JNa{ zZAK&npbG)4;@@>f)Y-@3GeEEm3fJjCTLBOWsDu&ydKi^gXg)lKlM7YCBJ9Y0yxU^| zyKT^CA=V1Op+f`uj|-Z2SD}QFJZ7Sw)Gj(a%9qusb&vs2C9)R<#6xpfUWQ>E;QYny`!Gv}w;vB%Z*Y@f zAkNIj77AqG*rcSVmZG3=oeqEznA;+Xi!iLX3bR=7k<2SCEbuzAnj>%O>WYAx9DY({ z`ZIa~7aB#lQ3kLvD?BK~q~cIWcFlsNN*L`maE9^$9VK+S@?4C1j=uu1sUqCz-+ z%u2JUc3TH*L^Ldan&=H!)K~&whzvp3#%qvuETO&Muo)|8T??ZK2su{;thA#7GeG=^ z1im@Ux`Kab?hIqW;r{jOF|?vR!j!YJVw=sspkINBBovYnrB6R)G?5lY^C*?*MTefy?DHI`$^r z7l@<;2nMM!YcoM-WvvlDSS5by;g@6}EH<*utue>}|ND~`dboO0XTzoQ?_wT>4e`HS-NrbyUtbTLE0UGYY4OZ4%`Mn|#xd)KpCMV}Js6P=OMnv8SQqx)T9D2{ut&1{8 zq2aO@iay22M}3~4hd4Vz6o-od+?h5|B7%efeM5igIwDG<@ER~6Uc^fk{Jmk17LgW( znHsG|FbZ*#hBllKEFT#J1mZz_2T{xshfWU}ymeF-?LXIQ|MLz^Pd922)n8XAlfuBL z5}KobR9B$A9SZC?hG+B>;t51>Inbnla&Fm4IM+gf(CZRBp>AN95sh@@spKLav@9GP z1wDe0mw+4Y-)v~--unZ-lY-%?~!X zIblyzFh*?u3QzqmDN0q8KZ4(KcnhY;cO2#wJ8l)4p6mu-r?vZdyaLh)@@Q;qisnxL zORWf~BM2Qw^2`MMju4AZSR)37)lX`B1rUATym^DV4EGl7JrJpXaq*rT1_q%1k1(pI zV>(2j%mi+s!*&~085{v@*5ev?D=oz62Pi5CJOy-BoF-7L2d4s84-Z61t*GJHqA9iR z3yj`6&a~DMvnFQg;Gm$4;Z<XK#@58oGBd_g$?^J%(--OK>8KBHvS@75=?0pDQ17P3+H<@fKd1~sWP7qzhMPj>H zrNAaoOQGw zK1@enfl;W%npV9H45KS5T>us>fT|O|#>X~eHF^-PqIh@#{tWzV4aW%Szyhov=B6AM zyVd-t58U)XL?Ih}MbBkIWAzw7ELE&uaBLff_JD!*2#x>7jqG*=$`{%T0^V63P)WGk zvOj)&|LRq`^Xkz0r!HNuZZWr_y%**_S{G^cCq%Xdq9-`#gOi`@TtrP+E6Wa8nEgub z?pkMbzv=%;Ir{lK8(m{VU1U6WdU+&zHrI1(XX=6!L;b-9ziONzRXbx=txR=m%Lln$g(NWi#m}Kk(Y5f^+ zTL1yYMER@al7XGV>O=?Nb%F3c@BwKADraq7W~TJzh&F#%SVKH;HobMZ6SiOhg#aG1 zZlmDow*|@3eE2F*sUV(1NAsPqsTl|aUhLiD)IWnmHTZIyF5Nxj-sw9%2Mq^eXufv)zs)>)8*Qqdy3PbqVTQ>vvXSFw z)YSa0tsh&bipX_Jo+PMHN_B`(cfn_X@fLE09` z!CaxTj58oVMfX=I?dGeF{b7jRSg00a?=6hY&p>XH>g?&EEUMqvF@obaxd7wakMKQV z@?F;jd>K{W*0hWwf`wL(|T-2(dMVPV`H87G7!+9}5frHj)zaUw{Js77=^>+e%(W z1_R-?zQV-(fcETyuV7Uao48e%r4hmDMNoXlK0F;&HfPS#Q0fK({C|^iz zn|W6mV{yUtn#1Q%XPBK7f#P>^6s58Chn*XG!U zGO*h*r>|1v*3?`@7}t;4DUf5&_V#Q~p`qKw#% zOFP9x#8i=7xcTcPz2_DhY8n;;T7#(cm+O7h)JPD98W37ApiXNOI~d16A-w--=i2Q) z8R8k(SJ<)Pp^PYXSorR3f0)$eS3ZDfP=T2b)%*7lH(vQdOUTE^X9$((A$;RV#Aj_< ztv#4@`Ia_9QJMy%tPNPkbzKldqmX9+x^siRF?afI)gq!~03I4aH#BMDytt2%;(~lm zxKPLj#N@CPGwBRK8?6u^k<>VYJpbst8>C@d0Kb}m7-coMB%n}7Zbz;&M^p?VbHg)? zVYtTk4s*V}?pkmK76&-7uX&vVohxP=!pt1dDBIw?-tsv=O&=YF*M{g-7y;t>N|E_8 zCK{-*d<8yWAf&|^&}(dkP6NLwZjLAf!v!Q7p&|8*J^=jnp^naqV;x*D&m442QT6Qo zC~B%slM@aJ33_OR#Rsyzp?A~<&Kh7l;aUb>6#V@Boj-oG0p7|$8s<4_W)O{NF^V%K z|40@hYlH^C9%w*EqN!aAEiinrgR?jX3@>7~Aw`k$25f_~RW{oAfuqcOkB$Ens=pHB z67r8#<1`l<3VP7Hp%qa;2;%Mv zeu*&c4>N$#p;QMsHBR}i~W)6&90X+!zaXbs>7 zQHn~)=%~)-=Y~I1*zA?mp^Y_bwj%2r8|`pWaFc!n1B^QdXAlcDvhI93L+sPk>%D&m zf&LGH+9kY%X$Wft@Y zezcBKOiP|V)4hsvFwK>gXf+5pX)?6?&&v?i-?IdguB2F-Za3`1nySM;v&L(wF3{LH<9OU(*^J=B!v{(OtLTN7^{eaw!JVsvzAjvPji!TINFj1u_w0O960xlRp+gUtgcEEhxl_9}w~>3>QAd zDHvS2qcimGn%g{y=-*BzBt6(cVS~Nrc}ilw>0+#UC%m_(eOE~(#_4iGPV;O2=jO$K zhcQs&Nm zJXEDVPGq5G^@NQg1*!S$2U}+Aa1y3Y6GnWd$_L@hi}Y$v#$#CwkF71?QTZ1R`^_aK zF~qx?p1kgCGZ&f+il5V)w4S?DAwy|+3&#Y5poA?`=jqcK=i2^(fjK<^uVW`v6H?@z zf|KvHEpM~vPbQ_>>5uXm61qx(bPFzlBO(>Q5c(5n_FM%aJCvwF-Prp6tG41n3+VF@ zJ_D}*w0iXxxX=Wx*`I1a5~Qo22lv$$s2T!*{VG#DYy#Dg_7|s{=b)44uJG_`KCIdS zYuW@xiwM8_7EH@GzaTXt6j|EPIJvx=unAFSp($zlV7nq)HAeS(>c}_d;v+u5-8Xa0cg-nYXWd4 z6l;fSI@r=Scv{%&Eo*!z!*C}7WCpOLj?h%u{o;Ukob^QKR`iC zVfz`l#sjrypwo=1otWILV|;tRUVS|E599%2)S+_4s7sw~?Yh9}Zmt9!uVzHsEZyE3 zR5A3sLRiNmehApgOd3{&vV=ltGPBIFM+gTNI{oh^KoF-*J4t=_0p?c+Pwqa9CVKn~ z8)a>6ZEj;D4+^b-)_Td8UJ@cYJFV{M_m!d^dw z_A))KT#fI3mndrUUu6lop`WQus(j*9i|M)Njz#3HjZwAp%i}7^!*Wi72pi6A4++@} zQe_CjhyNLb=ho;|a8R~fGU*uh$zeiQXk4GY_=VVKiDX8!88`QTP}qj41=>~$>7VzO zMa?qAVu5MrySWIPgS&bf9lE_{bG}*V-x2zK?Ay0N2t^-GbY@jW#SBE?4p4B&&@2E- z&dk~0lkR?G;8yB$;eEPlaM1#|)Ad+IUZa*kk`zv}p5!oSylAV&9SCgdw&kmJx&N`* z{7Qd*c~`vm*W_e$d%HZ$Uk{aU@h9^%9HN6D+KzY?L{1s*1QVd(%S;wSSO#)2YWn6C zG~b>c6hn)4Su3v!w zABsd?MMXtPC@m1$3yizd%gddb@=yIKmTw_WPg_W5f&gLAUukIRK?~^*--YOC^w6U_ z*b!!?vj8)-z%p_;GRlTwqnw9_$Ukb_5Mw&OecQhJZ5ft;+CeojOo_V&Q{v=zF9>xb z*J}JMuJ)GKcjfMC;E-5XlW%>t;56$8vU^)ad-d; zx(;~W+YttaaF^hg{PR_z`!fNZRyIz~-3Aq;0l4NmjfPCoU7@_c1tzQT#;9DU{cuo+ zl@)*&A-c%gWu}CnUj@U$V0cJ~SUS>KqBWWQ$^k|qK2qV?!$wNx=g*{|;(Hy4CiNXB z&}K-(IPLuWppn>v6Wwb~5lKX>F=|V!0mYwa8r+tjxat z<~nKHC&SUDVao!$`GL5^gOq!nY!3pp8Kt{QZPt!^)vP=gc;k!Sk>;3(VN?F^U=Y-D zSAv)b(~(ymmk$u-H@Fa)x!k$dlc zpm$3sgn>%uQvVX1&TD^N^&Efji;q1m)HH&+8pGxUy6Ph&DS!x{(T0`LHKHRj^(otvTFs&pvBNdyu0R8T6k^RRf>_IFX*7UgJw>P!jar?_Ca)J*zx{ z1TAj3f$82I>~5W+l4}P7XoeizmhW#laMmzjl;l-e_1g-gK@$~- z%w5gKU>8oRmxD1@5y7!e+8SZtbX`o$tM3rtvi<967mC+JE(7JI#Kp(Jb34eT=C>8) zoBP=)NiZH?@ICN?nhiU$kVm1>EfV|ZaEus?l+T9rpbHaU8Zb;q`vyoEG~3eF*6Ssu zc>WB@qJ8Xj?kDmy6(R!oW9MqzYhFu%^MpaHn=Y8)R zM=>!mWCb#~zF5DQ-hsW`R-8Ua+7D?kdqsCHA^2BxG!dLGB=58!&{WwvjK9=0G=gA# z`YK!(c|AQkK)hgvY7a+s=BEDkK*l<7+(T3no*L+Ux ztwu`FoNMVIceFuTTBWNf=tWF3VQ@Rc<8wr?zcE1o4+2D&bpg)8Xs>tr^9bp1!8{O| zEA8X|Dr*uSnAQfDAfAMsO0+FyV~;1|N$vVmF+-0QAS4_`q6i38cu zc&9vMtC!v}4YjX=zW%j;9v)OBiudj{Lu`sc^42DpE;Np#VT+prhTjaO^uVSN;oj(d zX3{!9Ara(t3);%e%1TPu41w;yxE-nBb&~LNj>eF%0)3YP0?Jb^b zttK&Ii*!*Xw&aY>T$he?h=!f9h2$}B8#AkPS4R0EBi6rNGE!8)6e&{a^Wnan5d1(W+A7^``EnWuLt zWZKwrBs)`~raN=uCKnh>aVMf!*4$PTUEb+3&G9I)Wij8r#qDZ$t@8AYLHwf2d-Ly` zFl^n!KGD2c^t7Uc&`gCEOfmR(#J${kObyf{tDOjGHCaEp@EL86b=d6_=O_rvW+p!W zQ^}Ow)MxAVNS{{MnM&9BZWg|Z+yh|dz%QoZ0ju^}V%q=#Ils>lEDYRgAw}3^L|%^r zirkqrC&cdjy#DA737$XiZq+BgoL#T$-Y}+>s={ld&Gt2;ZjO(q>|AhwMk&VJKz3fD zP%{Fau?cLd#1aLa@{QbbterrKwmoSk9rv%om19r1B=pjdK4cItVS+G7Wl?>X4Hx~B zm~-djpXnhF=|Y$6g}8DiZfdh_&*v#cymlBi znUw_Dx~q6jsZCww18N%ScRqmF2N9iM+C|~z-Dxvys z*p0s2ypZ1!IF7vThwAENoYB5=vgF2)nP9&0)dqxv1@QYL4Lnm+3CiE-uqQMby=*ph zlKl8;8cq*5VA_pNTpmY%GsV`uq7J?40o%*@t|`x`g<bm|)g4lP%?`+fb_%|hAg zMg(rYPV|xO53`Q*@1J-k-R!^XJvDaacDdsBfe*Rg)?Cmi9eD*xP!XYQt)Sp73VPa} zBkNECW<5#`4rm)bB9Ap=|I{$%Jt4>G|A-^t$6DZz<)*d--N^4I3L*7rZN`3&@@b_? zN>?)@xDgN~c!Jy1zyun-FjOqA->Lr$>Jhy&jPGh)H|L zu`??sy_v3GFOE0S8`)j+b{e``-&KC^KIfA=SicA2WUjH2U@3fj#o!_T`M{Zh`O&S< zIV{aDVnSqDqJJ~K$h5=z*K@GRrKz3FYaQOyaN87F&-CoBu4Aw&B_%XvKD$H`L_EeK zN4gWkCJg-^@4UrT_xC>(gL=$ry8g)97GOD4Tue#a#xeOScrO1wl8_7R=EG+qH2hQ8OF?cJmYk#} zTW`Z>fv&(oHcE`k-4{ZOh%GdZgpE*iP1BacsU1 z+x><&bLm}sEL2AYm3NJMpLW za9n#DS!8Pul+pjY5#1Q} z4wY_;^HIUIK|?>SEqZ7!>R3#imHV5vZ+w+}%!&o)W940R6bsKIsf?@M_cg6QFS_pNg;7L8D7wiVp@$Ve2HJP1vQ7ruYm<}}y9ZCm4bW0wI1o@h zpB{-1E@{0_Zg~ov1 zeld#I(&F}bkwe|Zvh$ndI1KC8`0+M0?Bazo9@fN~ll)A{*U8jS@N6A(e|U`1<40X; z*CfzYacPXDtWD~EwS#dx^VL?B7k5LARk<`HzBVrsXXyVU&XfpSc$6NsVVH{zVN~h) z3hVpLF8ofkezf|~@vHzp9zUGbT* z=kcz7cSFt<`#;ozpHv=inzu|=#mubHD-)Zv%9b)E?q3b%F%S)On|jwupYTvSNF*kV zP0%Wd{9d<2=5lN0z2@Z?0&^1~RZ>ZrlE3B1E+^#4Xa(Sfh<}vQswXMfNd0rrkQh)Z z#cX$8ZJ%9UPL6_k9aJO`HPBGYd&eXa`j(a)Fcd1+;UXmy`iV)R#xQQv+o$2hgda`$ z-=)VL_Y4MCdB52Fth&PwY72li_w}I?x`O8CpXCi&G;UgMlxH59Q+4YJEWg%JOiD)8 zgg4$wZ;G5h9x<|S8L{yb*8kQbk!hb*@sWlmZ89R2rL3w`i<81=v<)lK@qt>_SDGs6 zBR8ymrMuQm7pL|@CuW%pTb|0Y{DQ1qnQ0@dCy%%PEnS80p1U-CY6;uP8L2M zz-DmB;EqadMXpKn>5SXl)zQ(JcBlbbZ^mPGj4zGC`eAi$FYHEjU$JWoDt=JSdCe%U zMD>Rk(lxwgyTLf6e(g!WR8ul?>Z_0N3H!EX`2Q6@7&8<5{wv?_5eHx5di{5I`Ni!r zRD>radwJPHO(q}=!2D-6LQ`j*K_0Y#p~2zKo^JdI@9qn&|U zC+``fog}Yo)qlVX5lG&$9D^T*O^lVb8v<YGcj_*n*`P&;1sd zav_W!i>vs3$0uAHiFaz*uRAn z{HLGH?AK!*cBQCk;DH0=29_S<0_mO#T&SjXCt^p{nD0OKAfDQ9^H3emd;P8va^0Ea zGXJ}oe`2;?Eb-HeZ=LVsMRklw)IwoSzsvWoz6!?#z_-}};l}-U z;zE8{+OpTDZBLlPO}kD0x!SRK*qdC`WhNWEO1^zpGey=r^=G?vz z5rQAe5REb@Qkyea{)3b5sZc-n`U{G`Q}ZRl{UbwYD8&aVzRE&&!`_E+ zIUXa^#^^%7JrUifAA9(_%U-f+wG($ni+ujTDQsd|)P-Iv@Dkj~@{_?eqWpK|cL| zl54B)PACBX+EDb%@&$i;^GG={_V${yL&dMn{4&9{n^6KjJBP~nQDz%^esDyE4up>$ zswx<04>`UZm!!%dNn z{t0tw`4`4nGR%ybSNiNTsc?og;98r%c_&h6Uc4TZKM@|LbZ4eSIHRu<$RBlMaK?j9 z?eTyB55KVH4~MXw?jD^Vj#*Toqu$(~@*~2h%wgh=Dhud;aZ2tFHxc`#=Tjzfi<_Tw zZYT(oGtz%Mve$N%!E>(D^rI`LLz6{eO2RNQoW9prnkm956lq7vjk$AaMDz_jY#YL);;!H73)Ie*#lx>GJ%!0JH-KPZk|&P$<%{Rx>w?SU7a^ z0dVl(v?P9=ZT)s;jq?4JfA3Y$A=PAI$CvcmZ18A}D6Fm}q{ao*3FVuMk779{5Po?^ z@sowsE(-%c^y{qbS^lZ|k4GV=qW5KEDK;!`Fyia;t~>T7bv&%C#acSI68Nb^y0v0W zgNA}Y@Rh2UZaS}#Z)K$SGE)5hd*sRN!&Pf-(fAwyBouGvP26_@rG5%;Av9fb6q;Dh zTgO6WB{2J%ocC$-y%yDkBJeH`m#CkbGL$6CHn-hucSP^=HEijGvAJUnh{xDkH$8Dt zmK)8&Ok3FgE$sAbuVukWhLIxVx=yBFY1;LFuGPO&=**B^9v$RgbJ8~$MP@|p*p!Cw z%A$-&^RM8MIZckqn#4A`>DE?ZSr3YNuRFFxK4V^J3t|?ho??Df>XK-lsu0#rug^gu zS8O<%3;ngbFL9Y1qvU02>T9tE|Le!hvKzbb=4UDHzE+Nk_>W`%_)+w^j7e&v+qhE} zSw|(`VTG#R=Wyi4y}KXYoqrEL#sB^B2;b>Ew)xocset8}(^hocubPigqBTdY!#ntY z1VcG$=rOlwu#p2mO}6M=aK*7vgBFrl9cGp9&_{^!iBJe$zo`n84#^zRGXcxl*UHr? z8@TfK;3|46*E0~)iR9d2yR{*?0!QEn_y-5=F zrec^VcLK{g^W)T)Lr$*yy+6VvAIp~BG;?N;pAeuhI4eUR97{r>o*J-sM0OF}`m?r% ztDx|&Z!TLJ`R~)aB0D!jgpxd-$4pbx3T+R!TiTB8RbjlP)q&1Q-S_S&_kGSj$Hk|5 zXK3(P!{bej_Ug;j&NRr}pK|g(GtW@c%y70Pa4^jd7I9+yD^ubXIk4CQt?dkSOCym) z&mJhXK(o@(F0uC2?chhS_UyYw<)kcsfqWk$N)kez#CCvMRm+#$yLs`J8XNQt<|+?37H!XebE%kM^5+J$ zrcs@?o;))De5!w=D~wV9afvj3`Zl80Z1`s4a`z3}pJOG~IiNRq7vrN|sl5(ezYvV9 zT(Lo}E|YF18dh9~YzCv&z1TY(ZP06MztdSevb_NXY#t$g+DpQPb);UJw~yJPcAG9{@1y4i8UjJQb1g_Jiku?8q=4ob`RNb$^)I-}1j6 znzYoKbHqgrv3ZVlbZkznPF%oY^r~&bm~j1uGo8yS|lg( z1@6AaG$(xv)h-vCUP{EI=^ZP15d`AwP7#>zxrbx|K6GgLgjb_V_DA6zdkwuzUJl~c zpv&kCT^^9w005|URH2F(tgfjvr7~ip93IQc&N~T}?oa2kDe<(lz8RNFP_yGCq8UoC zZTZ!Gk4^FYb824_a)G$OGt6x))M^O+`>PH-e-;;$19l(B>`NW-0PsuGav0d5$Z*ugHby`yQ4EVp7f^*;e3{FZY8*@{$d5A}i}RJ#no znHoIkZm04Q3q|U)WMP*a@~d`}XO0Z9_Pd0crQNSGAPHlydhbvyo~z!X)$9$`iV6{V zcy7aGMA+FL#lvrJ1kL`=Uau-NFOt@H z%EjkEVQQKnmHX>bbxOZ!%S;Y?IrUg28zcOQ6Cz9AMEe(hy}bGUPbJmma9C1O=!)#Zu*qQ-8x%gUD7^})Ji+4hd*jfY zUdhvBZLdvO5OYo(Equ5#-*@TJIAnM-D|*L$;#?Degi1Cp)_1ur1%E{!4L^`~5Ldvz zfgfZzYBnhvefwU2(B^aWs@#ps-Oe18RNd$(&qt*cPqtqqzIne{HbLVd(MsbX9yqWr zTboZ{&pweJBxqWTCE<1AV_!1EM!DE4tFFp%&GW+LZ~;rA{cq-x_}KaW2eYlV(v;47 z-LiG;L<&zo_K%N6CsFB!(NO6MS)KZc4oNl$R+2S7xu8;PBUS#!DW8yyQ)@|VG?*pl zdRRvdv(u8zD}4(?q-cSzjJzraqC>dM1-=8c~8ZoQt||4y`Zq0 zZj;YhYOxxRw^R@N|I*-aHsKH28@#>~V(oavZ$1IY#)D%I?^C$``O*OuUkmx(2(R|k zmVH}->t2CNzs*YE*9F2&Pz~tSqe{zt zS};?l40P5`57g@1HYovB37Dk3vu4UFaBf8Z#$D)V_*tzsGyYq{zlX~HPkr;h;=j8u zJpJYl7QsNquP&Tw)>zgHm~Wz?-jNN!YmYaXSpImIv5XV%ej)9BuAK-r9jt+l5oID= zZZs{iQp`P^QGm1km2b+KK4C1s_t7pynf#8tJE0;uVPc2h$rNF)kk z#r#fNIqU4!O(w61=Z}PYOdc7`-!L`$k!guODbTl6vD4~(I$t<7o2p@HHyF87*TwPF zV%n)T*z?@)mC|$9`BM>$FRFRbCzeykbYHrxQ6mC4q~_j7bFv;7w=d=2GVsK@G;#Oz zS=)oe*J_cqQ^!6do}OBY*EVg(CYwSQ45r&GFQ}ehl|%XZYx6tW?@R4i37_dzks5jYkiiQ})_*l@NCm(`8j&8YQJ5wQRMtfxqOu5bIivN871frCnJ8W;c?>7+{DO*H${(+A@}E8RwkNnGnZo7OrcaJ zwiZOIa@@L!tE9PL$8Jci(cF*_rcu~VL~B{tTwle=>r)s@TUGuP_ z*IAuaT<}n+LF|MvEq`E$^9%OHWEoNMrcQ*1ir6)j?!(IQrE@^psN%UgeD*zkWuo2! zUlHqAd+PDj#HEM{e>tqOdMqAPNuBw%R%1N&rxyOB@S^q_X;QzQ*8LH0(?Vg>##$_e zoeBOP4BWXNk0r|ZdSIb-G$74Amu8Lcz;Gg?7Y!9c&9S{d42uCZcux{z_0SodI?kdn zR_$KYna!t3j^>Q5w>P(c$Xm4vl>XQhx%N%*SMqsAW}DI1D2GA5?23c4*rn0X%T$Cs443gde*Xvj{uw@!tB8jKdug!jO;2xi<<8q zx>Qu%@}C9Jnzo89(`=M@oKpo+RnzTj$u|`r9?-i9yfWGPVNLvtUg~AXz3%VaJBtsL zY(*N1ihUoUXx5*=AvjTCB#2NrEesP3b_2<+K?H8`nCxvpG&z2QNC+eZ;tH8 zd}R~6i^DGF_&zwdud}YvnA#k(!tYu94dY%Pj?$B;`^q}8gc`}4xY~@dp|+V}nXr z4ZT6Q)Mv!&F`pJ1iY1pKaIf?;;h#Q^mx2x}+sCvo@`K0LrSBcLrdJZqyV2PQN3ZM) z>)t(hR8B2|94PK80y1?6wZ!BBTl$NM+H3S$R?V;dh0v%Bf9nyQu6&i4>3KfY*7`o9itDmCuYWsSC8_Bk6H35;I~<-0rIN7@vS z5I0?(-~6ec9Vx{=ESc*TzT$mb4zw(v$k_Bcfwa`xmh>7 zmM;2C_@%ku8d(nNC+qRi$LP7oxFo4K!Uxz?=rdkx>Rx+PJ@gno> zo7YN%*nhGb(==Pse51O)? z^~t?LA-PbVx8!upSNF|BDpxztv_%Z+NPd@L>|L)+)O@JXMRTR&d-G{1>LaXqTW9Qp{ z>}^kalZ;T?tJex8d4|?Iz6yu}pb;=yz9h`Y+pkD>yo~ui#yJC!bLWey&r`>rcN|K$ zf}0v788Vk+d8KgjANez-4Q9ByIcI&no_R-i=j8cWJk0Dpx5v@4l=&o!_KOqWhsvww z)^t35ReE&D4QVf6t+(@lH^*(h!OS3;nx*&FR z$Vu<|8E~*~+e$H@H7DZ8O=K}9nW|;b{DZ2)dG?#?MfAJh*>+!aN9<=UIXRLe3=Ev^ zJ~ku2{NqOoc7-lhw$=PVky&f@G%JTq^B0*klZ7m~YVXPCQk#}p9kWSaxazCVreYX) zZjtf$99*PpxHc%CZS}F7dalQ@Dfet&m0ODvTiuEv@DBV<_8sLapY>%Sjd;4(pe{V^ zQ}tC;->-r2w7R_uUPtMxsda1Ko!PhHaEhLuZ*Dh@vc5_>6d`9>N*@>x7`xcxWob2= zww*finQWmho0zqx@j5Sap}Qt_p)l!RaX@6%$|LSdouH8xaO5_ChAQ6h#Mztp;d)WI zl%ejP8q&4ilJmW|?gWpe5j!sJO_V}UBiX`(4#S6kymC@@4_;a?+caZriiKqdIc{BW zpW`tb<5{VQTsxY6F`^fZMn9ZpGVL|7|LH5!(#2(XsFIg4%Xcg13~Hx3I1K6o6nZYM zbHDpSit6i|@TW1Tp@O2g%5~wG6}b%tdQMc{r|rdC-SIxNCrZU_N@G(kW(rj^Ra6(3 zj_%EP6+;0^IWBr|KUux*ihKPvR^b)p9%a zFHT|vPIOZTqLN0+N`$Dr2Z5Sx)!~*1s8&z*+ew;Xy5`}~|7|A>1nLbN^N0NLNVrf6v zy<_*Io51eV9_HlO1)g{1-*_|y{l5vnZLm;mS!V=+ZysUMa^oa(vu2qomzh?&;XnOE zL=#>4ScXgMx|9iS7nF$l`BL(_`0VdvWLRP|yM7WROv{IJlaR&=E?b{@>5FMHp$wG< zf$_I)!Y0Q5vwv}}cI(4k@334654?jEM{s!K$VrQU|A?$v-Wip)=j_?-Ialp7T4_xy z_HKR3jEP!$%l$QCY^v2~|I^ORCieT^b|}wLP0&bdKdHmDT|dE<5M6ceWPVgvc0mRa z|J3z}@RgDnRtsq8&B$^sGlqS+qGldanR`IH>gaf;u@3FP2=& z!$?YJ?{BwHP0tKU>P^#J#)#DKf|5%Q?Uo{A{Pfx^miP%iU!(ub=IZgJUsQ>ZHPW## zkSL*Cqt`1OxtoK(CgF@%Npz&E8aYI}e3XRl8$zi5)R}m=`VM?6-(1D5+8yq%aWogf z&_E;AkrKY?TsxN5Y*3-CBqoXe_&G~asvBAl*Avp$!{%)E>~uXh_1DfsSg(_5^=L;? zRh?0B)vqh|-)OO_LZQep{tsVo0af+ZwS8|;L@8;dJ4Lz?kWNWOK%}Jvq`MU9MpC*( zqy(jvJ0#GW`5GFIk>_D6_C06JO%W<4_Epfmq~l<;iN)UwLU@Q&P@`!%s7ZuY;j=ssPlG zgGPf0|Fx&l;vCqDX9D@%_+f%Tfw`G z(&K&1ai@0kdUPm5^-TE6l;n=KxfPGg+|ux-oWhuN{aJ%~eVdfQkXOX`#o1LhoLfF_ zKlV|ck#+2@j`!#U?AF6aT$XFdpKqqpf*vH+Dtc?!tw1+StCbQn<|f5Zx-pOM*izOP z#o#u4s6^ZgY}e^t$*T`({j7K=_`$b%D|gWA9mn*`SC8*?D0Q`zCy+AdhWZGl=SIlA z<;RP^&4;)%Ao(rNfv{i!`z#lQu;8~XUA%nE8?MZng70l1%tG7?2BYQ0#w-&q0Og4SIpVPkwx;?ZdpCyjZ~i# zW`$dq5#%<7J1XezC$Y~=d|UBOZPpsqU+WJ(ds+Mpmh=n|7tT8Q+R;?o*K zq=}?Yp<0B+m1`eerc3JWeC~897I%$!AFpKI{|h0(v+CpM16i zQ~7W7hYD2+AGcdNW)-&Zuo~*Ba8DoMNASSVXh%nwO-GY|-38(<&6PdR!KwkX9jU_j zilb23$8UbQ)eDb~zD)aYf`ULNi-3g?-`i!Xkz2fkQ{q!b=(5RCk& z=pOt`eqRPvfA%xNvdU!Pn0zu1MJagP_%KVqz-Ik9XfY*_t%-ES#O6*d5n~F;@b`wn z0%~L1P2RY?8?y~(WDPPL&#hsNC=PiVck$-#*Y&(S!dfpay<7VIrC_{m$4`9AR*Epf zd&YNd1~x0DSQ8AVzqX$JAj(_V_Y@`UKi8aM{iJ?%9)ChChciuqF}~T|N+f9fTC()& za1;%ngLC5(jSxbWs)3Wk@OAw94@(?JaLBH|74^H`+35G(vYn5or@cq31cme0>954b z(xQQl7kd2{eDT4bqp>uwmV3CC2}&b(kH>FUqZl@|uZx(OZ?-N~=6oNSeawEf#gA+M z8hq$#vvGQ>WCm}$cXML2OfE)o#+1{d6?4zh#ns=@YWkKyn#yNCRd#|K_BlG3!LB+m{hLp2)RXdY1QxeVx-v>d zEB@d|f0AYQd*7J@A@&?pQ#5@5r)~#5x5dIN*#^)3OC{G`*%B7MKi6CF9q+o=)v^TA zRLu#jE<|QH84M2D=wYXl0|?9 z6TbD#@;agw`>(BHAH})nH0*X;ije;px(aUx^%#5+cWnr&VC-&BO=Y3^%Hex-Qft;; zV6t1Dw*29Bre->&P3@;T5|u>9rjsK_tSdPQMbVl3nfddkjn3XD-$TV8=N>KBif6-L zaXr-&o$}U!;k+CDauFzN61VINj8DPe@6gDwh`)9_Z!y(Rg1Z0%XL z!>l;Kh@*Q|p71GB4`CL&g=uid6R|_UB=suuW*^d`^1Oi^sfXnEc5p79!nHTzni?gP z3T*GQnISfISeb;}#GhSQ3aM;9^Z(F}CJ9O&7%9q%8{DUNo0a=MIYki;yM4%IKR zmp&oc)YjuWxucvs<2zn&J;r+KIPB?WwkvLbAyjqz&apbxV%1W{#jDnJG_l<^C$DNU zrI8|^{hi33Py$hR!0-Dg<24LQ+hg6XJHHO9><>dz>JPoWhjmBNTv82Oh!Hcfg}1iH zN?dmmzn9MS?fNe8oSAq(9PrDD$Ig3Oj00l#RO6xs$r4n^um;g9CLJTl)P5_c_bD-LU1oLri&6Tj)CtLhustfrO7*0xsnBfEYj$#qNup0#1uVwqTe6i}hi zW`y*S9W@905gvQ`dsN~eqQ;XwH07Sx;k!ybq;}I;t7NeBvfuAtW@P+{K(M*onwmJo zL)JO%+C{yF`wMwTdma3zhAYnU{T`ubpWcl{yh@8Wn#z85yzcmPh?4j4<18GGP(7jQ z%JTl$sf~D{zWaa&jBW24e3|wvS>HH)8h*6_-ahRgUbzHR=Po}p*N`&bDf&{R zG2A6>o_P*7^=asH%Y-Ok@1a1HEgsoI+k&Ix!YU#glK~P$YY#(xTg$pSKpXF z-~8Tz?McV6_~+ZDqJEF`>!?6=lQVI>b^hU}_eA9kYu9T#58c_ZZrRgct>VV|-Mpsl z1+BNQ3J$svW>8K>!h6=jSHXVspyudA8?l|pp)XVVef@URYbXJy(_Dr0k5?%wZ;Ehw zwh%f!^4R$?OctHV;iQ3rf(`{}n^ z$JQs)4Qo5lk+88O5yYahm1~Cwxby5liQ3EZq4ySF$Gwi0a7{FcS;5V7WNZJ77D_G}qoo^?rjn0Y0(6kfpiJ%|W@7`Sc`Q1;OzQyxIK(cgjE}bV* z$?~`Kg!>QIo%WuK=ddj*e@}f$cl&|1>$anp>(t^@Oq~Nk#dZ#>l<>r@12G5!*yS{2i3lQ8jJm$kc z|5O4(Z}pQ|J~!J=agYT46bjKsT;G#xax+P=PzY{8i6m8Hvf^q7Tb`}=nS>sfHy;+O zD4-3(p-ekEi@%Q4Q+&PAd=5v$s;$R_s@=X}Aex*VIogJquFngsUA&kZ^dh7`_O)@S zFD5=&yM;hROy0)5+?+C9SWC~=m9bKBhW5mKR`rp-gn}+j?B2DbvQtdtC-eEq;jy*r z@TkD7-LCz_-k5i_!*8pkbu)O}byE#Ih}n;1V@95eOt*eq6%}mUV|!*4-`E)(Y#hU{ z5YW<=T(%!U$#S@nXsVb__cc-ertN!UQcXoA(MDxKz({9~@9pg&5PKexK9bUe3NgY| z0+gF~A2}apt_?6TQL{YTX2A%_ZcZ1tHS2)!$I&O1WltUVbDqOM@MZ2D(a=Qm>y2D^ zG7MlT)T|paPX-%!YnF@GnJsA2cI^1?o$*3#z2*T0lzK=B@Y+K-5r-}W@}!FCvYTH1 z_BsW10;ICQ@kB3l#C8TcTvbrmiCQjpK7+~$gKsD z62vv#wi!C?u{4}hm(~hx^?KYeOfziQ+Qaz?ro^{N-ioNSY&sdrx`b3!Gw8KX8az&* zM9#z>*nEf%&FfH+Z`UFCNuvW!2a8S1R(Ic;YlkJZL`94oUtpPMmlvQ3?T}Qvt%jl) z4eUb}udX;aYjFAsLR77IWdOP1#xuvC^L@ehi%JaoJyY%s29B&pC zL1OIpGZ+@Dv$}4)TV6Utd>K|?2%0<3_DH+0jLvWAt@7&&H?j6>QT+Z9Mas4oj7Ica z7CxIBIqFcIU= z84#2X&P7iy*jruA3|?L&d(eoz8+pm-DAK?Gwc6s_sF+2%wM0qX& z*VjFJsowhI$CQyD(wlmE8YV?*lm4t~67kZT2B(FE*N8F8zL0>hjTb?YB}i3AZtKM7 z7HA`2Y5_*wpc-_fp@sRP`iFTkICln?V6@ddv861z0$?Wa#Jw^6RSjbo=f-127jW2Q%0zQcStey8H7 zJp*rmdGAXL*HxMGeImojJ12?=WRRxCt-&^@53g`*m-$YMj$fN9{hwK2&``+h(mMf{ z@`~c4I~H1}%zwlaaIJj{5$n7xad+sW?7C+a#;fk7H@A6fp-Y1K5y<98TcRnzrFbs6 zcET`G8TKnigo%%Br~haUtb%rB*of%ZnnAQ@7U2+(?!)VohiM=d1Ixpx z`EgPyJW};8%v^8bcPcZ3s`6?4H$EH3xTaBGwJ2J7K0~bSC6mKGh;(RQ`7iGar-yD= zSxOowiD-c4!Lw6hy^QP^dXpFEl9!BNw&24m0nVd@5W8AMecru%sZCjW$>tU;@{{){ z5kX@ye7A*ockqtsg75tJdJVlNnp&;Z0lPP8Xp}%9xO?KY5vghcWNp`b|`(EuP55IOg7>%^H zX<98N`B+MH92D>~_f1;MYIPjQ^VY+fv$JQ!xKYs&AgfXs?x5ZC{pmKvA&Rw4riHxvSr(7W~n~F1vwJPWSCeso;J}^X38jJEz+i zZjFg5k3;(Rvhif)q|K3?p2^PbJUd;cW;d(LY{gobN>vmFjRt5nilO2C(v630;J~Dn zz-csNx{s*|9Hqhxr@0#Sm!WGU^cX! zSgLUT{tJ@`aX!B0xvNNH6PMXAnVkJ{4gowciHN$<5ct+i9e+!ZUIK`pwkL&u$uJ}9 z@t2^Mdy6Ov#gg{+6X%i`EQovuBV-3&<0RpC%-TtKk!qIY4%(@2CI?Uv z^74^X+!uN(0uwj_6Yw*;06B5N)+cLr-Um*!A3AWmqeGYxpw2WD6A!Lila_V71+WxB z-D@4N`l5nN)38M@*V)JZuKwv2v9ngO)U(zVa4r~wQ&-)?mfcj*XHX&MRuVF4WipPH z3bIB!k%1(846Qmrlyn4&!kC){(QEl2{w2=8I4F~8@c8NuVb`_p`ah~s21g>&JOjx@ zyo|hEPXnYzp~?Wet#Q}+^|x7r-+*g!E7MuJPufQ9u8rE>qjc^*KY^`LRL!9H94fVr z_-BI8Z8)gJ=C{ss%2;^0yDl~t`mn-+Wn;>}qXs0LVXEf>IyJZo6lB{yE{8NwO@*Y|VDMDtd zjWnq z2?P3&8V{$z16rE#3xf=`b&b`9Brhv^vPI5D;n$ozXkhbIS+{+j*B+_R=^ggq2`dT2 z8Q9M0LTR7xaebE)*;xz=G87Oo+9;ndKB+o-=PH8cG(=W@l<>Z+O^HzHMa>o750Bve zLqovg7i3dm=H)iMr_dsYEnA&xt1^E#hc#~NNieh12>&G};xP3D^_TC%X|*Z%D^qHF zZC8=Tp#8_+VW@ru(V-F%J@hc{iQ38$sqdwKQ-%2kx(LMb-y}=spS|Rd)r@rhv_WlG zC2jGSa>oAm!U}6MbY0duHC@B2QgPB!m_uZLKHYEZV4S-C^_-diD8<*_k`$iK=;)lF z&P@!9U`yIia^LZ4NjfQAdsJxrDu?#=Qr_#ljWawLB7mdvHn|agTkhRb6f_CH()N`dEnY*ioUqb0^6%!;5G4oRNBB*y+zqP1wE3a}Dh`;J zkOB_EPnu2Q1tX3^LFjwv6zGi==v$*O{-%4L3)hzuTej^)W2w0gRk26+1Sk8?Q~mjP zjX!q}M;Tt63>93Q5BK?ugGaCTm;d5?91sw%G9yX1ygfRcb z2KDMeRrZb=I(GrR1l#QLL)bL47!$um=c~%Tp4v^$8d#JLsv!eq3lz^jvvujG;9csu z(FvkOtjax|q|8YF`z@ukA;A{b!sSJ1;9J@87;0LZi$9HFKc0DT9Vz@Qxzre~e7uCX zZ~u#?(T-X87N+H^Is1j(S85iE9cgI}Uv%*Zb$bSNxEVOETR5S`woAp_%1f#@Mpf)i zYr4bOn3n9T8!^6hS|mP#Y_wl)UeRoH2Fi)?EH(p8#H-*J1fT$Q^tME2VY^yLb0HnO zv#q-g!|t$A`FFBp`uLLdGcg%1RKGx!M4w3-P#_n*xIdI0j;}l`bQI+U2BM zs|ssz@z{L*@(fD{_Zuhq!Ot1L&HeOP!%UBi+W$s2tiJGL#_3+1@FMdj0-Ar^vyJQs-};H_3t_ut~odLo5(e{I6?k2C~{0cuB7{#>xlG1f#L6)2q^GI zPJVFlYB@FaKk>Kw!tA>8ilFTDWS|9)U0oRpQT~J~Ix$U5d1>4b8L$MRnCa=_ZmNY` zA+m-VXtwR6Q0-^q@~S3`ZDo>)Co3{1g?;7FZ7lO1&S*%8_Sn+r)mjCAzB@I+yU?xD z<{4!z1q4fYYH2AL@gkbOOm77g4urmBqp)tk0b{Jp_YyNWcRJD&2v@d)jekI_&IJ7J z5arB#4TDTwNELrB+K2uLd>WS|aKY)2PD;_K8TVjFE9fnMPhzibz?BeKzm+@k{hy^6 z9?Bk(;DP}&tfwCp)sRORH={7%PPe98o*FYX{w3B1)MLzkAp-RM)^KWY+qZ%bKL2s* zbwqT>DOJfHydrDgMNZ|7A3keKk43Hxuz$O*6E58fM=lx8M>?*D0){d1=4t6jzdsB` z=PKU+A#PQ9gz4ltTw5o;fw&9ze|vgxkG=JTx>xiL3W54v@Fl~}#QV@?ZroQlaw+F= z8R-TRxr2;(iyaN&2ySfJ1%f>fY|3B;6J86K=TNw6ZXxZ1c_1o;7jR?T%n2_<8q7?% z1^qPQUw6;PEqKzno)|HyI1SEA%ZVcH-hSvI*RI(AMXKoLBXPTb)5@P0h|)&|@Ex7x zlW$%RncCHf)-S#8k?}tt>>m~XZS-ZQj7?bre$hIriqhA?;6DQkseB4y)FrAPz>mzb zvzJZEW+2t(biYGrQiqnTi7)k0oOD{+7?0ke9F*M4RWp-Xuii+H%S}!>LlFR3?5yi? z!7uQYk#R8Dxyz!EDgUg2m+GP#lPAskC6|amt*R95Rd+XzT(vY8L(+lJ?k`De_gQM< z>HC4XcN(g$Z_y=eOIedX&hp`qlFn-WNOD$*fel}v3?UH2bwoCdsuEf$M6N#oN-f7gLRLsQ(^2p^2Vi7Z9K%+4-Ye{~2C#EiFLtnUWF+W_=*U24#i5 z$}u#$0uaf$M)7ehDDYmp;q2e*vqTms4LNvU_;0Nqd_ac3GWPj%E-k5$CHOp3d`X+D z{Yw4yRfyP7oqC{ye-q-tvvYDk<%HR2Nl_A8H*T-0wXyULm>!e>m1zP8Pzi)A8Rzy? z>x@i4#A>~p_J&N@3mA|SV4#{_%2cu&Tfgjo z32KOf{BUe)x^R)1d4+3Q`Ou3&Nhx4vkt$QW>y=SMSgv3FLk`|+HwKL`?PW-xH%3!Z;dVm%S;z{hKzV%gFc)Uh*Ud8Gin=S?XUma_P*q zSQ#NH7)3cv(R;lana%oV)w8*}uzvh>KN4GYGjN8u7jjf#&sf5ow540WWh7Fr;4%k( zAYYSx*bvjIL=d3~QP68T1#;S5Y)YRY|*pD!7JV82nA+oX`nQs>;BTa+ct~~0=PNqv+j<0v^>vYfE z%I?L;_dfWTw9{PL_+oNSS}_X+J7{dy<=V1k`Aie~P?EQ^x}X%}D$@vLNC@?3*?0y(^R$|5`{F`ubxJ$OX%c5D@a&<$9+FOgurDj7d< z!IJmsZ)C|bS3_y8{LoVbY|9x_lKQ)eiFUv;8rjVMxGQ^+TGngc3!HDmdy*TgGUA~`s~D0 zYWUAPf|&_HMxlP7Xtz)yOvup_=_Dxa6u1Ohm7zZ zXam|DdFrar}bW8RkBja-;g0!+iOphRDN7L3> zft!(cX-4|Tp0v}0WMuON{88w(xZli43cR3=xc*P`(|UP!54Qk6`6g}OHeJbIzt(1N z@F@`Y4k6>s|CPrfMVs|kcZ03`Fm(<+$~{QclyTB|Neoomo1I^KG4t^JNss(aB&}d{ zwUZC065(wqHU3{S*Jk2BUM`$RUDD;WNe3(mp)*Hc1_uy^Rf05EQR%tuJLc(06*=9N zrDxkeDxCMS^1@fgJ&1xNZ4g6`2c5x40Cco4;z30aYSh5?!Czj&lKn@b0tp7ET0i9-hGn>PIcw5EGCvE-xIuB#+d6Yedn*hh#G9i^91@0* zRMaHd$N#?8X0^@-@YNBi3gmGLeyAeW>Rzi)4`Csq6>_0p@C<`iFYR#m{$GvdKNgs| zm7W(byr;kZXQ|drzmbLm>5&0j`;Qx)C&d}A8}!O`%;KmqcxX(HinKq@%$VjXob1L@ z-;5833Qe;{I%yML0uY9JO${Yf;-+y7w4S=$pu2XwjwM{_4Ys%pzg_!%K3l&LZXd!7 zq?)Z$uUq;=~Z4Y9qc3T<||%3p)WcDvgGRSfl2a!NyTmfN?{5ahy6 z7QHqe9v%pUM}yCOV|W~%2h4e`L;JT0`@rB1MleHbWBIe96$w{=``&$!D1e*vSByVe zc+UT3&w+l)ZLDWgfYlx{mzPlh@PU$Vpn>}%$@0{$Zo;Avn$KKQMv%q)n0_AAdt-fd z_oz-qMRs=wd@^opQ@5+vtHQiw7pb#5uH{Fq&{d=H#<^zJ^#ScE6r%QJ2P0Yc=7MZz zTY7F8E0t|jRJDbBZ)t%|mkDGle8)Y|Y@Z+2MqBVk_ww>2gIz_E}SIz%6iA|bi=f@e1 zvS7Jeuwv0*Bt_~^TNhU7?8if>Z}vhtrIiRDy|BrsCFGMU%z1-n{SQIC-vWF*VCABf z`I%fhvFMTo<{5qyCh+$xb2{g||8NDIUVxtD{7+;2jmj1;g7E;GFdPEJ$TR+`rYI5FS@{kiE zMTW1NL)wgC+7_J|DP`n<17n}4AvY^0Hr4<8tVB_G?e=Ejn-S&n;NX_xJ-wdmO+pN8 z$6mG8EZ4_>wu8 z<-g>!&E|nHEm|0N*`|O&mYw>Qxr)kJoR}|MaQVz4*_~s7vkBwIUmV4PlqnY;h#><$ znrF7JU27)Sk)x@1r#s;w7D_*d2hm<=BDyH=Qsld3mut8Rf^)R$%$NJcD72XD-fn)g zbd+Edo5Vqrxclhd^~+UFeWezIXahaC|L~--6MR9<1W)SRePKfSdkDl$n|w`8VwbGe z!I0MnuijXSUqh&M#;Zw2UUnvT0Ps4|>yA4#+4e8IFGak}8;|^9B5Fr69xcK|q!DnL zJC8T>F!O&D-!HY#eGy`B@#b9P^iRf&=uT0fCJC>moxI5Pk-zRRE`8_RXK>e`i(hvKd|*2dd~3`Rk_>xM(tjnTQh- z7&ReDKt4+uT3OGvvLtMci4jeJ6geAQ*0zCK!j&%vJYDUztC?{u(Qmk@LHnk+x#NWK z-A|m9CYz@0Stj+m_sLC{MxAR_N`BI`fizN=Qu?05$RO4EWU$HQ3px4Wzg&*{8iZ_$ zhkyx}W_|HpaAZ&ir(>}GPA1svD4)&|bz~%pE&qN-Y>J013#=R@IPPO3)JLJ8Cj{@? zmIQ(5ou?m}{UjMVNK}r&2SeYA$$byB(C%-*Tb+2G{X{4FTU(jac=>*w6i}}M(S}uV z{#@t~eNc0XK0s4FakLj@VDj}fcSjulmT-*r<;U+yuMzydXnM&!@nQv-doa0RiIt7@ zwW`5@uhE_KlPZ_WtjP(qx{eV3`N9|PO3ZpbG4#W#@Hi*AFO2+FpL<_I%khvS_@w_R zAmC|ejL>bo@${n02#CjrP+Z(WeHulf*x*a2^Cz#*h7>6oV?vEOu3G>805<_>UyQI5 zvVXffmOGFmoFrTfiYw9Xr^n>0oFRb_sJ+47Ej>He#VI3QE)5v1#CHc%IMO>hI^cb~ zMUJGvTpnmgG(ky|rPc|ExnkEribB0`Ov+UBczrzhGmJH;H!7Zm?mZBXp{`)VGgdJ4 z=2x%2JPu3#&pj3a+!urzLfm^Sy=(i(Q~9}z<_(bOEZ}4=EWnPKEKYAHQUclh$=z6f z%AL1@!ENC$TL&92ruN!&PcivjC1;fa>Cs zN0LZ!l8e2>-L+tg^=SFFKho50v9Y#?bu8>!>D!Rb75Mb8}k;uAJh zi=eH~)Q2RTw+%~Q5RQH_i`F|ynF733G;!oThHu%7wl z9mSgwj20hA35k?H+X$321D#q>_XeI!-b-r=R{kHf&Q{kxn|Gx{1~04PsDqc#Oha%#>F03e>uHdWSwgDucQhii~jh@4Txh0^ki`E(=Yl|o`y z-;1E5sn=WOxM3}U@cf(n3biKJGw{DoITcYKPzXs~b>B?vx^vAC*ppBe@fL8aYQ1LddAv>HJA5!Mn-ybp?#dLzFHei1i?oV>ipyMi>KD#T2=G2IwW8PGBg6(r z2(G$C8C+N+qj~=Sf4JWfR~}&HO-C)!MAKodaY&N8nfl$V75k}eOF_^_x|I`0bxx6j z>HCj=Cy(NmBnPBFX4>vBplZMIsA{II@tguf#safB*l*b?h1ccHe0=rt?D5fQ>51LU zBmu6(%XCHpr1B$lp-J8ga%UD_tPf-AzlTDc#zL3EJ{$@)t-{M~X@!&cfc7|rBSD11 z7D>7ou6d05In^`mlwMOYMm=GnK{W0fk7lNl*6im^Vr`H0q{@jZgj#+qFlc%VIPhd* z?*2|%%1Jx=)g}cd5vap=ew+cyzAg@>6<+CXd-pqVpaq2-{ zH6B6sW8&pKrbC*;s6@$Z(j%Tb2pEh6r=(zR#9?`z&v;B6HKgpUhF#3aYZE&3P4KhS zGAQ>NEs74nzLgnQnAzV+lW^H5(ikVOTmFPZ0=MO06ea5Kkr5ITw>C{aFjZKgm%dW* zNys_j`}$p_CpQ!^v5bP_EbN{y*k^pcCX96HAgTN%!rlsNm36%Um=k(zfGcYxBqJ%=>o9k8$e%Lq!(cU+hzYJdl7QlVr}mW%G*G^4(&FO5t;rLJ8aK zpK@kEV%R8M%j{0C6?dWjK@00cL9Qi?QmF(ZRCcq6^zv$P{~r45c+?n2^wmmFVEBfYSQzH@S>X%(v6m1KBZJd)D*GMi&NQF!&_-?a6S9 zA?Hg5u|N7_TX1&8`)Ga^N;xs{YXMr36QeBV?}#L__LbAxvQ#bE;1aj$?OFGuUjYv5 zo<9ZQRKu^i3D#nO)5gjN$<3=Bw=bb)%M$=wpMITdz%LcjpsI{0JkU4`Pw9 zEii-J^sPWgUH?L1Nn3JO1Mb<+G7S(^pOp6kc?Va<+d#1g*dcn{3qo-}A7(GlKBF1{Mivsn z44*%I_jq zcIhA?yUdASoL}d?K_o%HDb7cO5JbD7*V3Dl!phF}`MU2RHM@Wp$HI>0x&uJQEO}87 zdn=^!xu@jGR|+PmgL1V{?F+7=vlh+9)*%qaF-*^-8S$i5X6~93l?^v~bE8Yme=kV) zpL-!WGJ83YgzeE~yanUwSY{X|9Ca3Cu1yEE;Quqq|H29E?y`sFavqc zE`yfXD!M$VqfzrxhK={_>Gydais>I$@4diB`T@Q%hz&HP)kInd0@Y*kj@LO1_Q9;; zea@J@{PP)A&fwQ*K89<7%EfTJ3Vy$>ZgJTWARRVI?f<}?SbqFcWsISfNel*I9g$g8 zj9#l0iazm!rFzD$E^xBI!I+y=eDt}MiKe_s-UxCu@1Tu>Al2mfjPHXk=^r+{k|`fA z@l2!D%C0H@0}4Xgn2Q=U)#^rVn6lkSn&0G~m!DHug0gd?$N$mIPf--P#5dn&n>y8{ z(L{FrOPsq;Be66{zqQf4{`*iJ1b5QilC;Z8iP)fEP&$pf5396*F;R;0R-YmA)$@^C z%Ndv0HqqYd5f;TP>)?P^jYVc>YTO?hN4v40i)dT~J$JdPIyIh5WTSUDjZ_#UO%Kk! zmuzS;q6|w=Lb9>{#YMJxoWq@|Z}{Md!WQ`TfT~;;r(l%$TUjuGTrvgu9;eFl%0IZ$ z$C9S$fr9v9D#EtHpES<~S{LupFVZDL3&Gp`!9$l0dCu{R7VYXz*IuEv{n0MClvxvx z9++y>-ys8HRd7##^P(MH1HDnN&3uJZ7FLXf_W4qIBe=>;RTQvB&n>)OePfXSHsty2 zdj9D=!N|ccqs9ZBztVE-QLu%n*UvgiM>(zvL_l){eK zihHJ|P5P!>BcTJL3%AjN3s)uD3Y^8(Y-G^^EyZ>=6?auee*QuC`u%0ivlN%!=?xeu z@=~mPPH3dsdpZuAI@_ljHb9F0`4J&1gGNxkE6=mXQ*kz?f2WC=^T#)>Tyt|;)fD2@ z?cm_RJUtHNGqYwzX1b^$Z^eMC5;4Pse@wig{cwEdHEw!LoQ0v~D9N(e*y=-Zx|Sxo z2%X4}l8iX2g@pec5P(I7hhthM6CMxgFEs@$!Yi6X^0!p(`d2`oMt*J7JbZ)^6+mV{ zal88}Gh8p4m;KLUQC1deQLnLT3IgcD@W()9;`ao9zGv)(k7UC$6 zE3x5A&>T=;%i3XG0`?m`G%BaFUU*-X` zm)y@=>G9uDuFH<{SM86?3l5f6?-8uf1Q zq2AEIz$FCqml9ivLN`gXmdy-k)?hl|aLX3LC3>ceac`B(1{%0u38(Fp}XuQ5HL<7C3p z&vYVigyP8QBsXSZsLQH@*kBWu<2of&>8N-)n-$wy{i zo`jd?wb9}&L}#1-yjCfCL8PUk6MQ56gp3eF<}w{qwzezGuAI_&f3sDL-r-^P+C9Br ze<#JCO1k6jS-AX!@BLfNxA)e}&AL;s87{*`HRXt>smR8%Z=`vb3s2o?=D)nQ|9n>_ zXQ1fU?F|3C2rW22MRyVkSJTtJy7<{Nn9rg5o{RRaM@F6(H|G|GC%oIQmz#YjRS}h! zR=0eY5!GJw?0{!1EHsN<@wqfzP^W3ou?1%M(7vJ@8VLZ1RF17!mW4ki;FM6yFUe8+z_@AHl9E^)!gj#)PW*?;yj7RvJ6&zATPv9$3VK*R|g z29cfE(^4PGzt-JRO$l7&Ns!~@4n>NAmR-%;D{JNn98q2wEjfxvq;5@`t&_m(hN_S+ zw7$C|(S;H};^-R>3Ki4>>G?@7pb3YH4q93#z9c)ZN0$gm3stj*3_C-Z9bSO-z*V=; zt@;wHKPrs4TG^ShXF!p?F{Sq6Ek(r0KH&T>s_>JaC@|}%2TTZAMt2C_!Dt-=1VXp zjgGq^Kx#yAJmcx`zd_3I?jSF7g-V!9IX{lpT+8b2Vc}R`@b9yYj+Bfgp4Ywb!dq^G zZ5V?nS!4BfZZymBL9j(Vh|>zUBik3FZf^<@o+95I!RAU;2qagOvbMat#gtuMe@RXt z?K7AdkT!B4^Uuc`-1fWAeogbU^w~}W)}Ko~|Gn=a^p7d9AP}icS=NXh*nttv)G;;pUsDqE2eregLqZbvij=bf7Z*HcRRJn%pI%Y}A9oBM! zzHQ0A_Db?K$^A&iu9NOuwICpBg8oralG>qO%xv|4ziAj>0LGf-qKcVtYRJvq!`!s` z$OmwfnaPU=f9_J*egjsK2qWMjZ}2rB{6L?LmJy`PHsp+Ai3OKA{N3r}niKv@1^~83 zB78b+GXg`xc&PEie%ojQ+ZSn~V(gNaH6tl-UJTtl|{^2-&aEU5RpQ4-fQ@BbGlh&K@M z>o?z5PL0zIsZHS|OTJh@Z4Ew@StwQe$)%3GYsIMmgC+1=BjC3>QeFjS=Oz2w@)*ft zD0Y99Xl-_Zwo&Gq?@7`UUE0xhl}OOUFpwWn5J)^N_)D-KM@w^s4*T&^az_1c<}BwP zOL|X)r-#QCJxb)A4}c5*qi1U>K#?J8dQvnwi5f4_45-`(%?tKxJM2grd>j=}Xup?r zhFqc3REQD1J?iPn2?i{cpr<2eHS6ph4^GeU)hoBFqGF!r;wnfZ?QdXR^!o8Z%Q?r) zoM1^$6APiXx@Z&<&-~-E1NMkdHhPGV$JR7#fn`wiz+aK2q>;5=ucw@pj9-FupROJ* zNE4DGE0w(z7F_>3=WpPRrc{N^5^zP3f*pA^Oyskt;f8VlJT8PvwJFM% zd{mUnBj}JGxi-JFQmSU~ADoT3OnrFOLXO?n@x8hp@#^6{KYsOjm5CY(Hnl5SlR&~PiJ@uWMLBjH2%Iq3?_P>-m**u% z0^7%=i7G=_8xskzzW{S%MIONVq7QgX&xzRN)=;SevgqTc9p3>@2<(?fWwZuT)rZsDoMv{y zJLFN!*ckf0`%c0&2mh*wlYw@)kQq7}tQyDuvX#*@q`-8%8}!MC)aGL1196YF5B8(Y zbAiEMz3GmDogBX24|e%{YR*-c%Wr1Ivh1vdZGHi?0$r#LB*=j_c@dOYaw4w$om^b} z=CVj5STUF5$D+3`x(>GQkp^Js$}Hqt9~_eQPCW6K^sMzOEou&aQgyPV+4o-A>$rsm z0uu7Mz0g5%Mw1^ogXL((g&6Eugz<5t(~ zUrM*`3PgppKs+&PUiv!1u!VlO?6{!+7BZu*%}3g&UZxvC(fe&k?Q6VUZ7K{UXw*&o zoPyOI^`~}-&#PCc5PtW>eJF$%SF&H`+#aglT+FyzIw(OCe=@OEQt|BbYHVKXmqDY| z79^fCCDI!$UKu5EJfg@vJqn__WcWMVKN~nAy#pF;j|Cz>YDwMJY+kK2GMZ98H5_&B zQ2lmbd)T$)J2yZ#{(G`f7RrD{<-hLrbvD9 zFOQj{hn~7q6aH^(RLt`j3C3kdzmavxsTUf1t8n@qtDT zfsUsIrt8@u#}TU7W}2N+@p!3dWq_x+cc`OhTJzIk(dR>kvvxaNJ{Kzn$-kRhyOIyA zszbkD`gw2h`EI_}*c8<^kL8!GZA5)hu&j>zfUd`cxHTkx0cbIImF|7qI&1| ze&fz96kAevnFfQz*LwUjdhBF&w*DW_7CqgC+i&{Iw7;yo!nNJczEPX4;yrf@-~CCz zf(QmlI^W&cQmdK$lOv0bhpEF)*))5q6O{B*`WJfKJKq!~-TD2N(Kr2fu#*=~edR63 z44r#TWSKvYl&=_!MzodMmB z?=e}RyIwZ;)8FPHmo6k~9mdsCcEv6XPdZG*p0Zx*aqhj9rUT6UF$T+#jH zOQGc-@SG{1`BCk&+pX_^KP=RH(0pTicl%+Fqka!h@6-JcjD~!J^&3i~>2{q$wy#+JTJG_`B${ASxmP0@5X&lF}tgNK1DJ($d{1pfu9W2uPQJbPSDzgEUA<4&6P#yld|J zoZoq#_YeK3;MUoDt?T;Mwf2tb`>7m$2dLYk{==*uL&2SRa+A+Zf7%+a35P`J@l2dB znfzI74m;bdYQUCQ(7mG7!D&K^Tzbc!F6N8wQ{D!%)tYejo}ZqNIdBWMa9~zZ6-K)k zL)UWqTc%P-@llgTNxIg#@s@I-XVl@9e-XixGFjIocko@fs@`4$3Ms?El7A~T#mhz3 zj7g}Wv7{QiW7k?rI(k!f8)x1J6NfDkxu)krC0Y6hZY$fG9`(T(GIE_a12rUH)$BSo z;e0CfzeqmuyxhPlM93{QopFzl$J@$1%D}8~7 z^pQ(T+4n!G6=73XN^&JrTV9u2edsK%qV#|6zvNL{_jqjaLp$Q8j&0Pn{UqQp(;tDB zJT%uifahTrEvRn1yo3lW!>R8l{qnT$?Fbz?qB-huuJh07C(q=URl_Fbdkexe*Z)c% zohQ0X{2X!fyB7J5+MzQt&i0qMN=gG$3=udLxqMda)?#_&*JmbWL2se^nl zQ2W10hKAZCuBmP1ZsaeO2l#p{zamnWVGEcl+eVSBFMtM4t+r zp4;LWil5LGL^U>zK2Mx=JwIBGt|Ffb-e!)F#D$%rqv64MY&IpH=pO z%jqcS+_9tc@e#V*URqn5%N~;mEb>bMHwgtpH!&g_ncL|wp-$e7*OQdj=r}2 zeeXnZS8eTb>zG>e$IV$x{RRA^pq{fSz0^mJ=6!QNbjeDtn=h(U!#yMiOIQ~h*{MC^ zD~^`$&Yprd>vG)+Wzcxs4DC?dN)4}azF?VThMuE=O-7@1tJ968d+Pp8Cr16;fA``ouVTcz9^f?$h3U(BxQD_gx{SBt4`La?^_%XcN|8k3+uFF-^xkOgk6{d zZ`>qT_Uc}h`@S@4HNFHt6}_4oDK5KtCm5jmq5$j3DHE11>W?4iD(gyGaUpajjnaD6 z{xV63mXP@{h{T)EMqYTIor%n%on$0F!_xh=Q{2cm?czqF@FwTCo>G}`I>q7*y#w?! zZ+$SAz|cx0&T&|c^zqVa;OBY+PcL?)9{=Y;&PJ|W1?5=38!k|BaAOmqrZodZcfhc= z%iAm>rgIW%T7HpYg`V`XhUaK!>ojHVh|>E;?H{QIywUH|7Pwc#I zJtu)DxvST+6`avICYG(@cQcA=!bj+Em-#Z^2IL&8;kcupmr>8QNWt;rHps1jb28}} zG39dG@NF;B|M1+YdSMuI^7x>B*cF9vT<37CC&(pc55#G*Uw4+OFzib;?BY4P?tWLi zfG6QqzjNGhm|%vtb%I`WIVb~#&t;oCUCxP#@ADS~9+4O~pH)8E9*>n{Qn7*`-dsDw z_*w$Tj-|<-p}ymchokO}Z4~r^ zgTf~=8T5TpA1%-4BJN)EllyqfKRtPlu6`$a+A8n%3*{b)d#(k)V8MbB z*|G&w0y~ic$aZ8wtFZ+|VPU3Z;IU(`2h~8ggw;e-U>>Y}-fD5k0t3c4`95!G_uYDR ztheRu_Nr(E>dDkPIC_e{{;pXLQ|GZTNh>^K{mF5kFEHDU887futUmC>3j7vqD=cwY z*V(kGiKg8t17iF)*Z5k{lJed56Jw4UaJ;^pT0WbZB(PUr6qjxQC|`Qccb=Cv+6S=1={n*yO@nBag7rVWUT^ z*_266F*boXDy#D6KdtmZoIxd(2VJ?yGz}Cz!`9}o^}1*RTzFwuGp66lGGc)iqCJ$ zFBpAIG=cC)By8PO4L7j)1W$SLylMO#Add35?yAS-2~o-;@eH^B zQi`kByGEY;PQfxdmHz(;MFUiW@(doLhyD6K>C;(viU_{G-V=Bd_&BI6M?RCkT(V#z zK_h=hFFu&H_bHZcuAU5eUs^C>6+Fpx!T*)*{9=5X0eNd|?#+?!oz;C-^rx+MF42c6 zlY-eM;(W<8CiC5ya>;yqb+_M69{JRytpqabodf&ShJ8$BU z9y?)*rHZGU@#oKi$GYRiW&8a-#13nKWB~7oEW4|~FRSgUd9;o3XP(|Ub?4O*a0Rrs zAV}@!dTtT(2cLCl;MLF=T;_5};O=YVfVafhDdlB}LHs4hUlkBPqm&uNnbJ<>>h1uh zG41l6gydATS)n$Euj$d+O07u^kCmvr+F_Y!OHz32a2fA}x%($i@;96N5yhD71mDMP z`7uU)jvx~zxp#x3c102wSm#SlmfV)v)rl&%^^88RoFuLYF>Zc0W9!C|t4o-qkE8PT zy*&LQzqDHVFi#Y3y->WgWIoVnUuvx`nEj@8??C(GU9z$KqsvgKQY+oU+o8p(s33wD zd6U0|C4&2y=KCRSe#wl!+OAUjH4TZ6I8|oI`%R3WBmy5&_ACv@vjpF(Ss406 zKTDP-9wyJ{Q2b*)FJW&L;3jiE{mYw+jLyMm5e&iTmXDe7h7v|dD#6&MYGW-JxrToq zbNe-X%K3~6<2Tt_e1SC;IYHmcumWatjUNo*lC=8FWEr{n@_-h()S~LKb(f|sQ30v{ zg|qTb-=N;%g2#QMg`|@NV_BxnLt za7>&v1N0BHjM%-uS4$}gR$MR2wLh~9?sAEBrev)1okrQAcka$h;Gy+Vg?eEaFP9eU zyO*fV1nadqQZ*l)@zMF3M(-{U@gz3vXeH08YDPzht7R22@um1j>E|yGlPrWvQCzhQ zMg2o?U5*3y;5DbNX@F|7&6Z2HLTTC3458Mw3yc75s=1#7QObS@ z=)l+lqyCChk~jOaQirWZQjH>Y@)wQ6GTcRv?MkjBbd*wb*PWM7lJi*5mjih$jFp!; zlFqG0Lt~=X7LE?8;en@*YXWW)8)pNTf34A&k7kfg+24gqBADXsv0ikoluED}PZ41X z9L;*}l~zlPkgGlhjhy9fcm-2VRUJcL(?YPuO*MJe7@<#V3B~6sOA7R!*g<%&?E|g9 zJ1LWd39zt1xTX4{Gyhp}Rbs;+i${_+qDk+BX`GFSyf$6hZmrmneL06IuNw1k^J;%O zyScuH;5(f-=82I1l4zuB-IK3(RhWJRT=g*;UZtpQ{5owu)(z_=u%X1JO;7pHa`)-M z{oo?aiN46b%~z#Un(3@L0tCTS!3?HY0Iu4ncI4&_d+WK^iMH$s&2+_+oo)Xd zCZr6JtUBo*Dew3WHm-wyfO*ZJzjA4|;@37UB-v8Q8;cP%D|yA2wZi9p(*&KonsW$b z>6a@k7WeRB@LUhf2yEYE#Zwi1@V)TwQ?%Cj=JBQ*9$yKp$p$AErO=bqcTY7Bi$q)n zLT~M%5vErium5<&4F3&}+%NvI6B&JsMw-q4HT_w-vC;n8KJr{zdY=(Z>btcv=?@>+ zYqEi#IOZ3pSxJS@=Pp<9T|70jx1K@EiWFRaA69odO@IJ!lu90bW!cvO4Qejz`O8$( zaK-`Cjv_v%vggpfHd&V5cPk56^-lXC1}sibYToVJW}93H$SEFs!9N9};lx&6h4Jcg zTjMBg@RTS#$|@=PMw+E>uxqC^U;WU%6|->kI=5O|CfA~(^{WEi=wbECC=m=|f9tuc zI|0Srxu1}j&E}f2`T^bkyp?4KI!Y6eXu6M_3>q_^3K!_kF`tF1C&J>%% zf8{)H!Wb7`+wKe9k+=t$rID}{eQ0#Il$i8M?u;}!C`jv!|5=bE&dSAl2hKhIvbYgu zION0c6~BvqT{3PtGDxxM@4%$@DuMn_VYg`?XojF@Nn`$HJ0?gl_TEY@V&F@;wRpZL z-kM$sR!?Ybly%Mm7Jg%Q1&@1O+`|XC!rhj-jXRZjddN#-fq>dr3AoNpoXPW8W-ctP zP`xJ4XXk!r3?@qcNi2DxDJ00x7Kq6sK!tQ>3VhW)YWp7sUY;?w%{_+{!1R?un`pqY0!^Y=-DbDTHnf~(z(`HYCOHc#HljB2YTvT1Z) z-U*Pg?Bsr9~dvIc@tE_A&3-Oear|2 z#<=HgIFUK0F_*Hh?Xod7s;<4u0JKKSXI_AE26k9|$AQvW7)?JqSuG2*>iwerz#L5B z=F($3mh{Jcpi_O8Eq`lRA9vm0%h)&guuH>6yJ%0m=oi;4v#xog+22c48@(r*{5eIC zp>0bsZ_>%*JdR_&`4gQLmh*(>aOZ^r28wf2y>N>N&HSZRdY_a-gwx0CE)SV@ zTYwp(%;y39ubwX-j0hC-+kU!j60L9XMbE3)pC6syJV@R(P(+SXFRdnn)#W0alH>lK zC`}G}E6Bxli&u*Ke)Y|Wn)c3O`-$pp=iYU&nA3u$lDpErAg}LZ_$fHF8z?dzF>92Z z61}4A67bn)k}mrf{Y%p9LA#B+o1+G>}@C0;w2pX?Fi z_q-2O|fl7eBC$BQKLv-FIObC>-qfRQJ6_4Mgh1!C0uU$CGvwnLcb z^@_{?m6E(xOX1sod#_I=;#LK$#_qT%xb)YM{KdYYuty%w2*6rBx#0_y zA(|_3>~l`5z$N@T%K08utxME`#k;M>Y%F`_%R&fg`aKG=$*bttJw8CFLf&=7?Ye=@#PMCUiD;QZp&!gh7hRzC;|*P6DxO!YpH)F=pIxm|waBdHE2TSQ z2O@4Ke7Nisl6C3ldkb8F(rFz{9&kFOD|=nQT*pAzVfuxO+8E|rf79NXBJlV8etN#@ z|5EZlg;dyhV>d%?FhtjzPsS5jgSR4|a;DrZ&U5oPyv?ztWCpk+)8+4RpmZmkV_%FX zG`Nh)J(jXL6FPzCzJ;{%9R9A@8ErGeL6K~#<+*$|X&3F>#Mi#!pC_rA{a?IHI5!B> z05pc^qrzjIVoZ%FTEBYv+XVuJ7!mm`V)`+g2p?ho+oAbq?!6G}*0WWbcQ1!W&=Q*l zSXk?IN-PP(tu>y6PkIG-)6aI1%)v61u~Tc8`CJr^xvfu6h-ISPgz z@p%Ln0M=i7Oy?&R@Rxn}TNL3n9}|L}r@5a!i?7RC9&K>$CTfAM2kvl*y)0u*=WE7U z5$xjZC==AOEk&tT61iJCvRNOb?fmi;lqa5TCyYj8!ZLaRw?A4dxV-x$6kg%?CBR0! zWZtpIm+XLb6`qJzOJhF7bH6D{LHtmM4HE5>7CRZxCFo0X1dCA(=j;1T&zeE9@0D61 zpTxc}&%Y;&Y6*c?M|yV@f)2%HooA=F*_1b@_;U#N7q7)?fQZa@Y|^)K9#545gI4CY$Q7bkpdy;0y>nuXgcZ zDZR4alQ`65N>a5c0laHyde;Lmd$UQL;e)HuSQP)pcMM+wc;9@I;?jH3=LDL{#xr{U zwO|V8@4A{!JW)!o6~(&CB0R7Qcuqojc*6jKkLD))tcCLdqHZ{S{=S07ScPolA zpN)7?5gci$y|~W{pbqVSKpi@Di%U+C*dnoV(^%J0o<49l)>W<-(5x1DzAqq>NOwpz zJ51kjD*vGOM|@J~bDY;d&Z_cgrb5docP{t{^sy1{=tJ)v&f{wLJt?#+m7z{jty9e6 zhrY?{Z``J(7Q^*)&7!p?+0jF5A$zp7IvivegcNhkv|FVT&Mn7%^c;{{B8cbXiG!S< zN$)hdzc~I;oPa8vl0y1h^6nALv88dGD*{7^X+?t~sDmSXE2wCPD`^cu^akd}5O)J9& zd#|jhd6WrhSP6%C4CJK`C8`k5Kr>W*7QG^3g{GlwBoNVdo!H8!<&Km#{ww|D>6C}MB)L4#SB2<9 ztbKR|WEA5s2y@-zt61qKY~Ije8|$i`^Noe86pPKu`ath;`gz@TMYi3K9g_n$0)Jn} z?7R7wyZ=^dK3zAhf+tUM%YjsiR{_f(EYj?~<$OM&GJHtp=Ydpjic-m+CCj1d|7nA9BI8TW?MCh}fvXOy7Qj&*RoB1gs{ed>zUNla6##I?_kE>ct05*Itxu z>s!^2bE(;4cz|33FyOSEgWV`a65cMykQF`H6`J0Q=QkkCZ zDK*xJmu-1PlL8{;F+#3xe5XaHrTd;~5ygcFl_6Z3n#D#7YJX z_vQat5F)9d&*jxyZ}Rfi<~P$xw+cO+V{nheLYAu+zuq8FHk8%O_c+uO$PC@H+{?1w zzcT(Jqh3$yQum~KA8t-Lquai)*7y2n9sUCrX~xup#+!|(BO=AH$&D9F_KU1 zjW=#po=Y(wrgR#yt6HXC!PZN#bhU%iKyhNzLR2>BPHkgy=fJJ0g&b}9PFcS3w_@Tt z3Imebr$2k5xZUBZ1I9$fZ6Un(8FD8zsq>6v^G6YsX~eoH{E1>eD?&)TS9V4;5Q~)( ztKs(r3t@HA0aU0jiJ--feO@S&$yZm4Uul10{-DwT*+U&A(0Xl6t1r&@Sh~FYIyhsb z1UqH7{I4rwBxCKl&U4OMS!I`4-FV;bTs~Wte&@|Q6Ps2A(3Fh*{Yl7Tp^FkKVI|xYt^Ge zK8g=x1wETlUx~#7)_hk*kMOs%n7o#0<=&v~1CiLS&-(0=`eDh0Hk3M6ASS?mQ-1de z*iEmusK-)`p6pH+A6oI~*+oIc2V-2`X`lm@frabr>qwT;&xdSM;J-MAm{Xyg*A63i zlYR_&8d4+vQ}!9`eb-t0s9Z9=>tR(*ipL@;v9 z2mcIv{Bwh5wY&E$gh7Dfk9g%`pP&KBh-5i4XpXjq9wKy95c|-sm3Ox>7LQ;XuRQ!@ z6+H`O^DB6PXYlo>-b)Z^0Ld27UH^ba8+0J4kbYSx#rK)6`%y1MoZI@Pid8?p8}hGS zK8;HMEWj<}`JW5tp8bbplopYoQC)27P%u&~zfYp-1NFMjMia?P9&dAA-G63rt}6Wg z=Jx3<2_;>l9LKAQ9a;Uwi}g+DNdCiDfJ<-Jb&6hXn8-`;kuXf5R_|BMiRt1$PB6*$wat9iXv4HH6F8kYc~kV*Hx@6 zf=$$6-w)m~@<@bOIld}aGmn_8aooxg_ybRWe9#>gFA zH=g+gEI`|O{lCdEqtwot9YF$>%1CIyId7m4eA1~}%YD#L0?)PnndJ)~t|q2&@%&5R zys{n(ktU9O)_kQd4G*%Hac;~){f$_arW!TE@8tAt>?n8No<>3ee(|V!h3`$VW^{be zY5l&*T1f}ttkxZE6?tkqTzlDkb!Qu-tE~g+l4sf$p!Ugra&ME?h+ET+ePhcPVIhx` zvU;bVEdP+d#`?DuE1P|P_5b>f{^_H(T!XCLIzP{#WqMq2OP4$@xO+%nZxgEOL%a+b zshc2avR6UUG4|{Xe^ZKq^BnO!Ce-?&Y-#oa-0h)cOo&1`9VZ`K=9k*b*&E7v{nzT} zy112+G0j7RVRpU(dXUh^wa1v(t4Ty(G~(qP^w1R?@0BXV4m>V%&S)57;k+5N26$?A zoyKfT2Qgi`@L=8|Ysn}4*KDmu7`RT&|)si8VC?X9#S(8dfXJ zsYk61qtN=|GhdNM`cx@6La`3v%!K7T6}-1s^MgPF_#s00op=wpnDA?=AWW`?EEBwJ z26&LW(EP=1K9H*wqGo!_D6(yGsq6h+>uU-`J=|S!yix#&vwz#6l zXFP3vMGAu}93x;KciY5oKgwnN-)9%6FX2nkC>p``>==Q8W?h?p!0T%)+&cU#$_x5` z+mPyo`Z=jWtgznsB{itA-qzmmI9M|>DAIZ9_8y(_MMY~+UcNVF4Lpw}C*;V>j$?DA|CVdvkNK(Z%06Yjcfo!Jt)dmclFG>&$+1BBot($G$YHSfg1rLb|YNn zm_RAW1l|$<<}u?e0l8SkzqyXb(Zm15*4`>}ho*S@8>5{RDpBYfuiBiog-@#f8#2Uw zT5Rx0e%=i2;&R?Tajp6H7XOZM{HrB&QQ-`1;kH7C=kAjS?>MS@lXt&Ab{G227JIQ% zTI4SkzmbVAZqRI9dsA$AHy1l>h0*O@G z?xYHw=}Q<$rT`zc3*>4Xs^MI2#Hb|O)i<|79hVfQMXvS%3<@EApUg;va*^jL!+-T2 z7B}MK`8o*NbZQMot2X9~sSp*df?VI_<`nr@c@*f#^thw_0_!_af?^U^Kbf<@D5;G1 z4}{X7=48QqS6@Jk0mw%EHSjTE+5`#KiADRJBkyUacNF(x%$A?=fL<0XZ*nRcm&@{A zye^}Qu`f(OhUaNHiJGipEn?2;ov0kpz+x0Z_9>QaJGkSiLCoH=^``UCeL~;}e6fzk zu9Tb?64L6?Oad3Y6VXXR0Y~SqK>R$g)0x`V&jCv{{Z$4!Y4!w!RAYjI4up_0{|Jam z1S<=R3cX3}3M)hZHcilVv*_nW(iRlEa}lM0&;_v^ApWHOHh(@+P;_vlu0sYgpGw+> zBCoa%gZqP4} zBgF8W)#P*EMd@wke-(8K;sf?28uVw*yri=V2Cq8FOgNwXdy29X{jPeT-`z zj3vlbq~i|`ghtt#ajqrP5#!xz3{Ypb%GIya%5l=Q zIchx(=c7X&N551{d^4(LL*1tk3yF6lQr36l1=~I;o{8M0B5_3WL)0yEg7b;xvytMQ zyVyxhM^s;OJ$$iot3a(pd4(@aN2-r60zBX}e>0v%zK)52Z$HCEC!KXC{iMOEk<>Jh zTJMhqILJxrldF6WIYeYg&d$XnAi{&}nYO<1p(k{_M4qcq-`|N(>f<9=QD!C(BpAVM z*TNK-Y`WQ$K}3|G6H9rW$tUQ*`lag-0UscMc`xr*z3|e&+sww|W2bv_kCgUh zfC5Tyauey>yBM_@=&W=-<`gmS@y}%s=UBy>|Nc6tE5)-nGpxrKbML3=>^DOLd7)n` zi!DJD);T&ZIv(r0U3;7>huHg;hUalh9tN?y>v5IYeS03i0V@0OwR7y)7$oEzRzFHG zCwR03&>+t*#~S>oK^AZDymF;eV#RBD*|*akWJ>35N6djZhFa@ff30+G1DMVvB1QY= z(J})lGfT`$6k-<@(`NCr>s-7F(Yw>~K$tZJ6M3#W&|1M(xy}EI>*2l?kWsJKW>bJz zKg!P&Ih7Ld^9HU`eB{@&76-*Ftn&|8;_ySK!BrLJDPV~&I}}57W%xA(a0c`Gyye%` zb+zU*A{05?Aqu1$A(zYLkMWi8j}L9uK3LX@1>_PKiKsgLerToxAt*_6xUFd-%-AT# z4cEgZUghS_7mU#UCm()F&$&|JEGYW}4@lbp1UX;MG064sap!q@abVGsSJ3v6(vnC1 z56;9duE^8SL-adbXTM0&14kYGpQA*v?s_@XDufx-Mp*M(dcr?PK%gsPA-A;pJ>ac> zr<6yPX>EyYC_SmuI@=(zRaWx2fFkuT8YsZlUlKG)n@bw2fb?+fES4GtWN^5H-y5yF zw@>;+m@UNGI3m|+PVHwLVN%64x+EH~XE%9Rwc;yOCce1ln<(tzE4su^e?nr2e5bRL z7aB1KtL|N&1*&}A=|BT=mW3v~W9bxBF4NyxEeR*Oo6hU@CK0pyZ_Q!2xzTE3&WGYj zY%?IZb$uKmc{m=CZ;ptQoS>5ZZ_Y?JWK|y36&)YWElgXHm@UgC7s_cIwujhv#lnsqO4Uu z@A1lw?8IN7LU(R&iAqFfGPW!Qq%5i3T%1qfnNNO>r-jb_2x4yY4_Aye8j$|aN`_Fs^`8(h{c$vZ^L0*-6*f2m+B$_y|efSk<?%8UYYMj>k8E+B@Ge$s>Xhf4O z9gH{3gCI&We_{%^%&j=B0-=0i0Q-8p>ytRJjzmTTU3TZIOs8u7YfOEHEC z!(Qyg&>fv8e`(GO^JjNK@vr(S8)?RYL?@s^YX76(uo!L*c+zdM^}eSU7fCxwU3H2N zvTprkg=6Y9i3SvERGxBnDa8dmmzm_|&y|8)s~yLVrHPbkYNNnAe7DEg=j+r2S^8NW{}n@hg7+Co_cZT?ho>3gg!A9KPtz<_h0+hE%^c~ z0%$|}`Ke&Ur4bdXL>ir=lxlNW!6nF@08^~sVEXyyBSXFuIxr8TzuQLdjmL&ELE57h z(26E(<8zlUy>_QgRJXN?J9SZ`1ymkbT+aehfWNwtG#82`F6Vl4Fz9W+`T62bz{1rf zbdGyDg00fZJRNGz`Z+T8Xs!p(eAWSniw4Ax;<7Lg7f%qCC0KtebLt};{Zg%DK#ki}Ui#ptUe z!B4V|zY7k93f7X_cNGn#!KT$$k?BbQcr+$?Jv5;rS6S^_Uq}H-S~EkcFp#T~dun_=SKv`EG!03hj&Cu+;zpkLR0) zqy&VQUMznGMEa~I$_*zV}a_(GQ}t6>xe6T&#tn_@_kjCBmBwXfLe_RwFu z^IERcUuv@SrjV_piGf60d=iULb6R8#hpheR!Jox8Aq(X%PfhC^zcXl8bdIw(+9lnb zC~OXN8cDhiWvluq@irghneprX3{x&1=j=&-UB>vy=n{br@;I209ZD%7Lu`oaeCqHl zpmj}ch4Ie_Sq|fIHG?(6G2NJ{XabD}ZYR(48XEd)OaeOzy?c9ZE28QKF}p7dM*xbF z^BQ;)WXc_8oM#RUR5Spj7#7GCKi0a)v@cAwz=-HjYe=c$x@l$}_h{fDn=*!8j;gI7 zng`XQYUL^?+MNGc^m~z}U=WNQ`WhuM=WfQ!bs2KSKrmBjSh?t$}1)l?8*-2+M{JLHjA7>j^Yl{IEr~ttrXYy6bH~+t6+W!vh zuq8cAl2sX(o+Y-S^c}o;f4yiNAhac&r;(sd8Vf68tg?G9^-;Pob<@>Y+ge1KmhKza zPbOZf5j)L<+fU=0SC0z#P!gWUyD_b0Mdx2=d4Wp>uNdm z_T=^65K`b-5x*28D;sr?pB&H3Az9KSkw}ERL{%FTj(I{W4c*ZFuZK>1 zRxTv#SBIlTKezpiXPW2VDgmA$p-*;})NlVB!Zw2#s1zK@#T56jmpdAfZ=P15p9?eT&S3YF?Q?Pub4$`i7|MtzZgTaGv zK(w$}&?jkT22wd+b#5c_&x-iax4rbNA6Al%^*@xVAk?_4ty4Vkb>ed&X_>r?OhtL0 z2cK9ek`-}^N`8@?QUOkjs>oG9nH$DJ2q8=g^Rq?k~yLtXZT~? zWBc9dD@)-(69#jMi$!;4S?)R9@m4vfCPXqTkOHa8oj^j|+J(&WwJRTX7O7qyMMpW{ zwM<&`+{;HkyY)l6-D~dn<`E)Ud#HpTDEH(YK&d~OEDqnLZW^&Xu1CmkQ>gkSm&ZJl zk;`}3#6@ZSxl4&Jz8skeuJt%1zR*})VV52&a-_^Ke*~P%6A|VF6G=~E!C?^yXP(CI zjqx30mqbkEoo8F|17r4#pDm0v*U^e3e}oy!Q=*&*ykL)YBQ+r~$H+N)*0S zu|{4bDJPI1h5YqOlEprAl7$}dgQ&vc9zgFQ+a%$P5xEDwqp@#tmRpRiLQ?Qx8`vRi z_p-J?4&7sQ*Zj5RqCR4pzL?Kn!!UevU28vPuY~}93eqdWyKqR2 zvjkICoV-7*#KT`>m&A1K1CC#nv$0pi@EDPKbtOut^RNrRl4^Asr+L${!!DsBK^76ka zmSn_q+tWmA6w2-``dQHPIG5k%HKm=K?RffcJaLgc;$Rt7{Y5VTLqdUmypJ_rg-QFe zWetQCAKZMmZf+-5yKWq85sDO>sBlg4Vc|Lzu%!vLE(H^hGHZ;?Y(`XlyV%hbpm%%t z-QVQt$5$5j5FvMoV(oW!ax=j9hiz+Cy65_^<2l?l^(uT5TAc4YHOib%QMJCcPb_Ze zlIV*(7nHTY%hfO>=Y(X?c$U9Y?U34&bhOPcKK*2M3jdQsa@>(#iONWT!~{qI`L!+i zs=p7P6I@CbL7aE@oO7QBf4-bY;JE+}N6Fg12Hl3P)bRzrLY`zN{b~DdEr13;m#FC& z4*YuXfWo!TbtdT8Hq*GK_ze4Ca04=#U=I>*&GtNC zZdkyOtlU>!AXX(Emf&i#^x`e9l^fN|7it&YNFUe+Z{lL~rF*M!ee9&)uYx>LvLx}a z9~%r0vl#u_oT)J$J#7Nh7b7Z$%7~dYvN22sSkb-8Ujln-B|V4abKm;2yh<}b*HQoI$?Jgv4MY9z%03VyLrp87 zd0X7ONO~cZ!wPz(A<;7!LD#X4L^q9O;HOd(r1-1%H=8`%31Xh;AS=0&8?e&oJ~Y8E zq3Z0-dhU0{@QN2PDlCy;`eq4^L80sG#3vYgMFc;g)F{3Q)b{WA1NioGV)yUYDOe-; z{inSDH=6R=U6MTuqttaqWdCU><_<@o6$OtT@v?{>E0>KIqZycU&w0gUYClsQ>Il6_ zZfOxCAS6r^a;6Kuk7N70?TO6Wy}8Esi)~qRbB6H=37Zq8BneE)FY}6TmtVcA!~X~Q zGe!YqfHv_ z!x4oi!m3P0T1q(3-p(8EcO6}B|bi%d^x!a;Jk_$YmX(yqyygm+zT`tZ#9cZ>G<-weh3!d1jBk{TJy{4`&;%4wV(_ed z#YvtMJhniQKypH#C|L@rk7~dflgK!Fxae>hcOxVBoqVX}G^4PT!K9{u@)+O_$klhN)N*2Ytyd#1ZIAINIR-i?4$Q~NZI#*; ze)OU?*9N7@aiTrc4YrS&cR}PPi{m-(K{X}H^-gb(JVq!cN6U`v{U)qvm9?8ZcZ7`J zi4IRhm@ONX8sidt1~mIG{doaBKn5_K%wXFM{4;GQ+>CfYyldQ_vz+G^p}cmJft1-# ztVsG}^an{P`wqtxn6qHtGfq~^1ROJ3!&9koxVL03Fzedg%i>N0LpN_X7ixjixeB6jZ>7&(fLTIIQ~rH#Fra8(X#lKFaInDJ zk-en%uo-z{Jz9_}gJ54}%18Cfa+AKB=!iBHJ)!%D<9Yxx!OYc|k`myTvWA$PU`H=) z*Q3l9s0q|p1%uCzkh`adPApxtuA2J?n>dvR3vd(HY5&B`QL(0P?~XD`xxiRZ<0Kpf z`BWYK(@^zOZb4$epR?DEx)U{!3%@9->>)Gyq?PVcB{BFvrc9jTCYhqb@bB}lU^bkV zY5wCBd~JHtOu+>Ngl*_qTg_02Cf0d#X^}Am?ajSFJ;7!w;0w>W4vV*(j4EmkgsP_T zhE5s#;?#;9>5&EAURp7!W)H!#L`%!I7g|MYEPpLW@xg?4?YHtA7vEeSmK)ShPWL+0 zW0= z_>$X6fVRABZW|2+hZVr#GqZN{kdEAy2q@&kv;Mi%`6sy(?40!p8u5xM^2Yh!O2L@I zsCj%iPwmk8@Fz-hT*seQ0H!q_U>KdnGR_bljdRyW@H_T#%-; zN<@SCI##V9{nv6ncz?D%$u7l+?Tvh?GIH&$hc4_u<&Q0g{0ivn-^>s-(6|`D#PBqA)xzH z*=@RwFR!Bf%Dw2JZXgn|=zZ8jGGhIF&Z!IMWP2)hceb9-_v}45OHW>1oj_IM@|`q- zjh%g^dXSS|QW7#gu36E%Co(!Z`t#SXwY*pfv-7=qL!Zt3o%!aJ)YR1bj`ZL`kIUw3 z=Lk;hI~g0VG8Jipu`-VL({mFqGh;r18NO&4@KsSYp7IK=)~rtqKz3OE`)8$L$Ji&j zGPA6NZxfZvFdgC73kVw)|`RgWLz%L_DQWMDwQ|(kvg?m?b*(_(u%Do0} z8g`8(0-bE~Pa`B>%f|#))t}i#R6HD6k`|E=S25{EjAyK6sz2wt$(_W=4PVFDn-TRVmpA)?s%B;r@n_H<)+2OB$H($YO7B*Bqm$~qH*!92EJ7uP zg@uP8k`NQ*)fCPG>M#Urj-}$7J2{&*0pQT>!nwY~dB&4n@-bejCojXaIYoM2f-rmo z2L-xg&~((Kcy#(><(zH{=0b*8Aju50=5*U9a3;DX5hob#j%(Y8cI49MTT#Z`A-RXS z^X(+6^oOzCIQP!h-a9;1n;hzgBND$rLcOrQrT0|Pc=2Ur=T_nK>->xbiOQ;`3Cr=* zt70u%)UhLcXGN!|!Hvt9PW@S>9X{Lyl>*#<8%}N>g_n)aed9kwavvh^A@$|-K19xO zML4ZCtL4xY-E~g6MA`qExkDeHvTbu?=`Q*m zT_1WokjU!3S84XJE8ylzOMW^cI@;pkZ^!EDs%HAuciLGLm8r#Ilk(epLze_ADzEh! z+`{v$-rT-nM&4TMBnc#Y(WeD(sYny&@=P#;h{OBBUX$PCWjwcFBnB!NBwN@^VAs8m zGp-x=Do-PC!Y;jR7u`Nm$zd_oUZv-)ZWh%DHz{Bn8fwXoqm5WX}UEstt! z#AB4BTHRwQmYf9N%g0Eze5kWL8qMhv!0o4PM#R_&v`hM5xL*J4$X`+_!EqL zJy)EBs`Q_oZ2ZZZWm0`R*We5exClG{ubfj`85UENz$>qZFC7~pYn8jw`D#`63sM0- z=6ra%>iexnN`8i}EkRP8pia(ef$Byy-ktCUWmot)3|k(!!XnQ?16 z*yCf(p8E$);=X6!-_P)J5Mx_qd#~Hr|Fd^{S5B(_U-&h@vdTWThVS(z(IAW_*7W;U zKXtO(fIiWEJL@futg5a|>M-qG31<#z^J5|Zom*DSASn`EkPEXv#ydLR*#$==C~L-t zgG5iIGvtQJf^U6YLV47LHa&8F_km6m@EXv-cvcg`yLjpZrA*<12_~bg6577h)aUGA z2)$r0{V|ns&xKe5ANeUIH;nQZpo|kN2wUtua!+$b&zy+KGiso%9x=_<`=u0@R1w-p zBMMj^Iu7{-!%e*72%s3-J>opE+u<|0eejcNA1ms}f<@`5$pQbdeao%HV*H-i|C+D+ zy)jY5pW}+xsu#KNE$>`74HbJmT%SvS4F)=?)=4IB5U+?Nn>_c8cdw|$ebBx;{(p?U z1yogS*ENiVf*{=`NF&`L2oloL9fEXsgA&pp-Jpk#Lx*&OG>1-+?(Y7sA5gOXxHy4BDMKba`B6iKUU=&3C(H(8+B43lZad*7mu8oW z#*K%#h<-8z^x!vxa$J5lzC$RT-9?yE><4Q&pAYT>c;e`%gxBg*SUx@s#bsO?l^0r7 zYray6J9SteG#bg0L?IJQRVh>zYgmXBx=Y*~%~euY$JfOrAsGl|Zu*7FJ@;X^bMvd^ zv9#Cq8PxgmccaJVT&=^;B{iqi!QlYM&`!nmV|YO11LSLBWnb66cN%Bz6~z-EJPJMu zmm|5WrzT9}o0AJ#LpLMN*c#sQlqo~Rte@Vhk_*xUj6*5pccW(=9TL);Ca^`GH><=| z>kj?ispViO=txMCU;S9oO0uISN=l70_ypf@h|y$UzIIA+fYQ09>^ygl@Z3zAbM&-7 zmU`1hf@YE-S(OFfQf0Z8eEqXXCD$%z#P+q@&Y|ni+r7=3N(WS=QE>yJiTs&K5cA4P zctu;c83-mVc-VDaH89QiXvGwMo}T5u?wc1`Q^JBJZWSY7*t+rF9B6mtoSCeJO)kg!`l{3;qgs^Cc>o*9k^`e*T=teq&a4c2r7AUpJXgzbrRcRp)iM z*!Q^)70)y~-~xSvs1?{qMzom6%jfd4`%H2i!Lm`n1QMuoKfj{ktgxEXkMoYOuALn8 zDnJ~QkE_r0pOTTTl2rZSqzX8`7z+6+8v}I*%gM@=<>Dexl^^4Mmkp+kKxn3Wr<>P! z2#m`SzK@#rgh^73$G`Wbs#c*gOO1FR$y$xg?pLyhd3Qb5EmFxT^`UjsNdc_w9`FES z9o=y(SOXtrBo9rOOw+0hecL|xAi&g$GUDCjxJ|PMU!Q%=x7(xDD(VriX%CV3^l>+% zcO-D#zI%Vah&@WYQhvYRJ21n!IG^2EE}M0;$J3l5bU(FyXSyOuz|B6~%ILqTk5zKO zd#`p%eK84%M<2AcwRf*VR&jQ>j`r&Jk6;pHux1}0xPtAtIckZ=Q4b9Pw>7bz=iw8Z z##3CSTsf-)F3x;9q7n#O{DA5?T0~SO$c?a*jLvr% zi+-%=InjVPv2UOz%H)`LN0!KtbM;WK^Fe-_hbH~2q8w&o9s*%mhH}486~u(*d*cjOQa=h-k~r!Oo2=a~u3x!P;iv<{4G1mBOsY;w zQXafQ6Wb@Il`~R4H3>c`=n%tiLq%sl=!VGoWge>5SkEI&H8oz5$yQs&KT|?NXLs zt405@xis zf5oXu7LX^+f}b_F-L~hfxMufZ3z;mR3Txw5ENXFtqHZH zGC_EtP$hX2fg`qfsisT$@hT`Nri9*9#vL;!>`$$lu0AMscJER}Q!7E%m%4dT_oB~y z_^f(SW9O97@NlU!j7+F$hKt0@GUV#`(i3d=XfxrVUwxb~ufoK^QQ5zU`qzSjW-^#C z4^tqiXJ==>)+qNE3&kx1*(iccNKR4`sjRGwlA3x1s>R(ENj8*Zl0I#!;ct>^hd3m7 z{aVlGmUHOMxpo}0qn`IA6@p<`Sg@%N&ar3R{GR!wT56U`rNzcS0Ycyfnbtb#V>o#_ zOkrH_;Kbt*hJ0G~xW(|sN?Wd}Ah;X;m|BYNU3O!7L9iSeCiblrDIEOF6Ob<3FikAC ztV5mKmJiC71K%N_)O|MzKQxhMGn9_*pod+lQ0kB{4rKn)K=zL=W;0NdjFhA1Ex4u8 zNdq?{j8#fP{N6w4j39<7cn7lSj|nA?RZ*@w^1mygjT>5(<$Dzjw!x$02JppVTf1@pVZ8DG9S%67&hg=4?nH%8cRG}KXv=^Av^EvWp2aw?E05{ zr4io0sWsE#m)=s%=pSsRKath1)?pp<*<*7c!09mb17`a3pzV3>?b&SYAK&M4+Z2bu zt>Z1|p*+rZ7B_P<))oWFichpb9H*kD{wmE>n>FH$_T~iLxjt>-{_Z@kzz~&?BdDb5 zDg;l@GpewpDY>X9Mvo1ROt4?*{&L8+esub8&wyef_`5Twu%3#8Ey!ZqKy%OG#E{>UnvP`}OL z43?~5+xq&OaUK=+$-Ox)H)PQ0Y=7v<5vh#*=PpDu76$QI;FET$8Ko)eAj)ut z&13Oa>r-8m)Q%UcJw%oh;clDlOUvW5mqF(QkGx9synObtxx(b~wX$KCUd#T|JPxZ2 z_{c+(S>+(1syC&DCd0Gs9om}?P&~Bvp&&kb^z;Sgt5<#(7p|6EMBqGN{KDX} zoccVujasumQ^g?v<`jEU+dobHw#8SP^rWaQwB1yCLn!T~{@C;e-dDc@M{#DeyRxd?}?DiWlHauP% zaNHWpZ=D@eTWIonFjePd5pK0}Hav#KCIKYwDvP=<1YTU=^oEM)Z4B6nbIfct595*9 z=?{e#wHa87$I4_-C97nC;#uk01x^GR()^dC*r=C>G&3kQL+!AUWiS#|*q}ue8U2(s zgDDv*2NqvnN~-;0&4HRlPBLf<4h63id{8N5lc$CCk{zg-n+$IOiI_41Mw)w@Qpn_% zX5PAN3Hh>;0`~93OT?k(BMK_-rxT$b275_iT@ac-CyGt%Xx)?i!7$BQo2z-5G&U8? zGSL|GlJ2DULXl51Scw-H-;|;D6qOH>q5M%YZ$=_&DcgJ2SyH^xhG$b!0W~GNbJ=c| zygRN<^q}T+C0pf>j5+Uk$mDgLZa(?cPdFAU?$0=1mCWHQ^VLIFbDa{^Lf9sS5RC;u zH1?6y-&hg|upQKZr|VKu zZR~hC#HoxxfzwJi_}@qd=O%^+u#K2=p0!$hoC}a3hjvtM7PAq`uP6k*x_S;8oS+J> zbK1T*o3(McVPwq5+ift31&&-8R6~KX`zdc_0L7b9cTXY1s@>>)gt}Q5F7;j zaPf4{qaOMmwJJ$zX~V^~0GV`wBvc}^TNdosp);` z$_&_xI*flg@P){8V^`nZR#Cle^ivEYkBRe{cvD4;F?xi`tWs{l|&iKH?`E`;93iWY_xs5a?Q zYHn8X0b<4!3Qn-)Weso5rdSP|hJaRm)1AHN4}RHoE!rAXE8kzLsI3+vn}?G6xb60!LA+qeD734; z>xd>q?A&3=7I&6v`O2mz_PVdPCa^i_Deow_UsgLvy9nQiyhMK~Bt+JHzjBTbJj>>- zX;@jybbErJvJyfzSf~!R19o5)*T5=gYqFE@m0U|S*w;zsc(4t+38U$9qf~(930(Z~ z3nSuYT9UB=ebWXklK=n)@&bpCg>Fx{7VwXusft5p9pY1oyg|_O-hk)aD<&mo4J)Yx9}v@=&B#H87RJwka|1YnoEHN z;dQ%hvc+MjFGQ*DmQ0%cGO6cANCg!S-Hma=pN;;m! zd1q>LCQ>Tf03ZM`?kR+85l^=J9D-v|}Yx~@;k|Pq7UC6`$X^E2(3t{|nvVjfcb~Y$CrGlsdBZ5Je z#Ppn}w4T`YCJqY~40IF_+4HNjVuDr08K@;I&6VLje1MkPSdym`eYm9jS`60ZVli)j zDsPP0Xbz_xbHIkHyW{TQR+`(moUl5HWEKX3ww<6ux<_D^#aHG0Ppy-~Uzr({3&MU#xr1ZMtjow`_&`9X$=cJ8kNz2JyU$sv5E`kiv|g>`tr zwQ-FNa@GueO?zLpTOh)QgEvjs#F-O!>;^t%gqXBFe}knSdPwt^_498RSd3lZO7)W# zIL7VMBGc$lNl^Lc;>BhaUd0%O<%J6UK~pQ^<8}Iul`y1}pQZ`zGOmoq#vv=VG-B6e z-CXSs{2JouNgvX7T+;Bq?F0jZMo(op@q|)_pw*$Ip(U11DGNE?)RE+t$6eylL~)!7FiNM>y&#(qI=)V(81cajJujndiQ-7Q4JER2Ld0i4yF8-_|_ z^2*BCx+QouvpPmPpjiUsRmwY2t<~epEz9Lo_gTw_@nNzGWMJ{@nQ!;vE3WwvLB3d5 zBNqG*`K_PWhm9QR`3zUOpTS$M>5|_}+CRUMd0%`%!lJev|R53{)v&20k1K>=x+pOgmjB=(CD?u7r)5>@F$>9wMnW4Pw>Fc zWEmA?Qi|?)lww_K$^SEE%bs(3k+~DaX2F~mCyc&VpZqsr=zuBMOYbzU1f!i#1>s$| zix=7ruB;#*>I52bZkSoNS4?edbuD#<5(w#H3d{hyLs0iJ(*0X&7JL@Ma7KPE{tG)} za9KJ`p;ME@mgS;^usgs7+)MH1j%+-9J#lAtDpid7vjMwbyA#W0PR~Qp>M2?A_(2je0A8+dwQc$EyJ9{|k_m06zc8J&$KVgu>LliF?RSU-%ll^f}5D#;%9$eVhRL^^QyL$cDR6VV$ z!!mT&=JU-ix%*sfgGo5r%I_T zOK`XfHwJxFRdt-hTLnG9Udx0{(Z+efpGWY(f%^UN!!Lxhpv^YkQCK#&F&te#R`~GY z!{)6$4>3sPz|3)Wz|Bm0n^%xy+}TQLT)d#M^Lw6dMRNr!E9=`&MkoOwmMQRutCG+M z(2EiQR-DV7+a`T0p|rarTRuz@zf#e; zf$mqW;&nDHGFUlwPIy$`Z3iiji?J^W{X-{oRj5o~gvbt<3Zev1QG(Z?hb%$_q0THS zVhDi1hNP1fGfsE<2pNjUXreg(sh3apa!pA*1(`z>y+k5spdxs}kPk5q)s9aI=WRWb@LN}AOS()@Je(#?aFOZbP^@-9| ztjeuEAQ|X+ee+E!4T>99py^EU2d!~}&}x;{wriyc>=lQa zHs=!gfx<3#G@G)7x5KPovWlJt%*aZ6NV9#_sb)EElGAY-PGGMO-Jb8HIF& zfp@+294_lAtN{Mr!MR0v_V^))F#X2tc6L!zUxITcL5MI(bHd%+-1Iyh zSKC==&H!aGw+r6CuWpby-iL?aDdy2k?q>Dyc1_N@08l9q1OfTE5?@TAO~^L>f<&Ep zFW606z909EU~a-V|FJs9Eww?V>Yk_GpKyc+2ndp*!pb22DdoyoO@vm#?{97@TV#x3 z@xvW!8Y7zMV&CS(F8C!Uj{7|DsnfU=)2LFPGZB_IyEi`tF^1PVqaK>qA5wKIjW2Jk z3eBaPkZX_-qvSuwXTmIam%kKF-JY5|-+0i~wj!`5oOX`ALaGvrTsm2r*9qF*)`@=q zie8~`e2#0kdN|e}bf#DQHsuzV*2G3HiWF3@oOTB*3DE4$rcI=co&s>0nmPt4rUzT@ zz^qTDWTr4=07)c>!Prul97KG3}v*OYUJ;^UBDpEWe&jFB}1Q&h+!GTsnvwL5K%n{-N!N|11rg@U>II6&|E2iU*gm z2Mkjz4lxj}E{26*Q;jgm7*g0#d_%cc+3!)sR)T1B}i^AT!Q@2u%V=*7ZkAi zK7gGp@&OY%6ZpQ{B(iv3qmdnL_FKRC34f5yD_`B?ea}2b8-TNmYCnUNQ$!o8Bj0A#>Q~-b5?-T%xMcy znmOw=ucmvSJY%-7D1@Cj<9dq$Y5Fe~Z|)ff*MKp*k&zK+@uVrohNBgCYf(01N>v=7 z6VNk0zmQk^O6ED~0$S`h>b^^GFYO%J4nTDAe9TAsh zo>P4=77f?O5)co9swFLkhs{hC;RP6w*fo{Z^!qv{c(~XtG6q6u1iwgJaYPzeDYU2t z62%mfYS36>!$09kHsBcz;fpEx2XMEi5Srr*Oky`-1HQYOv-O^&JH_R;zA8!l%F_i9 z3hgIgm85}+8vaz8QuUgOxlq99Z~U5TjijzZ*Kjs3hylvZs>uLmQh)2v^|BJd_eFvs zcFSFym4%Ri{Bd*8$h2i(Gy{}p+(2o%$4>mO@qno$rVk_=9Aj`!>6n>mfON{}W~nAL zI>+nJe}DUA=oWT#`-dD)Q<)MDY{e&gm=F{3Z3Kg|{>S3~oD zUHAFOds(;eq~VX5a#T@kRMmtyS!saBQ#O(10krx~-UE2?aSGe~y==0Dli#MO>ay#} zJIvrmU9xZzJ>?^+5blz42;|#7Ek!Ozq+xdVVcPq;5Z2lE0Ox!|O;DlR>rrD&a*Lly ziwp;3Kyxmd>Zu}ZI1Vj@T#kiGel&2D93B+UFIl z=q(>DM(^9~{N8DhfA}>vZ~Q%?vv434o?@kRZl9?8u)KYlH&%x>N;Q9ZzN41(&6d+Wjnq#r zQZ84bWHeKy^3S7Q!rW$Ey$unFE)9}4q=0F(yJ9u}(N4oM7G`cZg}FM7@3_D7|6bS0 z=6jDv06Eta_e)T7fd5k-Itrp4Us_c)_{XXkIVwoTf=8&B;g0x4Amy_(YS6747*M@7f?}9wanMM{gd%ORD-zAq)q5DP_nLSubU->SEsbBcQq=v)xRU%~&F>iZvh zRIjsS$Y~y@6OAP5RePs;lkPx-596vj%iDe&pNOD7&If|G9j3 zaFQg#5%)ijg7ufTF9n|H$llyhUp$=b`ZZ$_C8`61nu*u3UWNc_vp|4?0#@@wO!%{#)h>k(bBFLi-B5s|0FFOW#6CjH>McM z=J(N5q83zkuQe3`GU0U57Aba(i-HK~*W`bhJ>V?Fjq8(x)oD-}0FRgF=ZYLT5#C{- z#7$%zUqI6OA7!d&;!@=67Yuo0=Afa9-orzqv1#{q_Gyk^B0F1VKL5^1^nN?G^*Ot- z11N*~NLnBomGR2}T7(Bd``QoKPyAXxPg{y-!Yb7>M7x&`UE9X-9r;LbL$k$l>&zJq zl^kV2fUOm?Z~rnE`_iz24n*RIUy^MOq8&!i;a$Dh;OD;HUY`4#V~sKcG1nxi?1Jbt ze$(NXs!&ZaYisxp5_mkuhY2~d&Lh!;a3PG*@K|Xv{B7(FHE?PY0>JOm5y;wLcimc# zHVO#1#NcGeftCtJUF%TPxV6!ZUpVeNwD|_=MJvawS@9#vow7p`bbo#-c;4D2R^#T1 zUmdB8-cL&PmGtt(9Hw<~{GH%g@C?$1hzJWm(B1DD^7sdjBqx91CoI@5Hj0Gk2XK8Q zU0qV$HRpOd28LH$T;M$$(f3w=Eu-UO0^Ru>7`lUkF`-YzFeUyb44UOsc375>6S?3THMj4Yqn2e zlO(_s0}KWOjRkhLd4L881G)=03uW|}_HRhR(kOCFkEpV;awsw0+b(0J5D)`b?N#{4 z^~H=-$yuiqI6NLsGhA%xDI?!-kM=R$ycFA z+3Lk>^OHg!n)GnB>$2=ZBVCBqdD8Ni3CgLc8UfHW$ zAbo^vg88`Z4R4wpwwQ1xOutMW80YtzTyRCFLNPkE_4FbD5-Pv#sEeT~gXKy>klp<)mxw;V+vgtme8 z7&YBzRSe9Ni}21n1d3^lLrF76`v4{iNBclIbuQbSlCP8_MG_J zFkkNI>cT`p+)g_gQGfX5ZT3o`l8TH@N^0s{LmH;698?vHghbZS@x-iC&&`b&4CMeM zbHE0k6idq@Z4)dj5l~C5uB|;Bt}q#r>a!s9$I+-g5>tSurCzN)!}|gO1ir}k;x^Vv zM~xz>FW}`pSO|Gh^rBO*1V$a)EIv#eECpm|?4{mGs>#XcZTbC&!dKD3vDMu3f6hJ3 zMgE_2kFlnpt4$DKsx$`xBW$gVJH2y82sNDUs6Olw6Y~>xwZjgQ^FA>-7$x7;1@`Vc z>25p9jG-d}rerBfp`vQ#o2nCVQc}Q4LDi(cM9!2UOpc zVwl|!D!MN(S3CC(4DH;L*%W&Aoc$Secm;ms%YpGMFsTApVJ(rm_8F)q|J#(Iin=uK z|2AV-4IILy?8g89Q6)KwvI&ZL43F3qjhlnxTlKrak}D;pOak%>h?gV|tZDj~+T>qY zY=T(H1!5(PE=18J`5#0IHt?}odpFGD^ zrWaD14>)@JvIR{DY})^6^-C#oMZZi03rqWDyMnZv|x67Yi zzJdMNidz1b1m2XqxYcyE{6*Wn}2>@>NXF?Fd$r>9JSGC%pgud|> zA`D0TTRT~g;s}WDYPOdN2gPTNPo#OHOQv*D8&)_SU*9?n2CdoG&Nx1dhqDWIGOXdS zjh&s{&QzJ7&&@KKz(LC+&T6l{+Vx*OJ>(1wp+DXQav3ti5r=AFkVIKo%Kp=UfUoD+ zxo*@sv<<1~<7(4ra`5`I`$?qgMl75hBZgiAYav(ZQ5qc;Fvy4VBmFmDZE-pg`QKRq zT<~Cyftkgq7hBT6jNCp933$deaBt5aoP782>SNKKLp3}X9m9PSG8*dPBA8xsj$RlE z?=cd7;>gPV>#7lg_SWE^}59(;rU46kP z#m#vm@D&Z`{yp`u5kAR+_c0j3qq557F9uc;ZCDF^bbC6EslUb)Yt~&2{xc4sp?5kJ zdU*+F+axoiJiz}ix=jw37+!#BB0m0QOPni?kE?=-1@iJ!X~=hwdW{&p_9*$dsx;@B z42CxyC+eCS8d5>p<-3PE?dCQise_(WS2qGeyWz+eCT^G_=ZGazX+s?o3}%?bFP?&t zRGd^m&!;wnvdU1y08>wZsO0745ypJyBt$i?s7>;q61}Z@{}8t&jDQ^4pK+KUUVK;qtyN-OAL7sbt&$tEjLxQbkXCqIb_Blt`y1NZ^F`y} zW<w{<+oM4sNBV)b~Lzd=JX#>v!8y%JeRL!EBopa@7S9Gmvh* zJ+lto;44r@UcC8n@m$j{&vX`VJ2_g85${g)INISYj1;~-!fkK*P=Icu z=UsYypd5PiBSdRlEule4pH&l^vJh6?8;_E^TS5s^V&Bpr#bSAJ~mw9DJOI)7Afl z4}vcNm;AsG#_}Q%t0Mb~zhpZZQX*m@qdfAL*)Td2xO4*HBqGe0fHZ-Zsl#%?t_}=! zk~Mp4xgNjgyP>~AT)GM7TA+EQkCPI7loA9tq8i-{lLfrx?4z!H8EPd_Bz5Bog2>#p zeQw-PD##s(GUGqxzyv~$hAV{;;uX9`-i=7EnLA37@-MK(CMnml;{RqrD}o5O$W!r$L}M{&mK_86@?B1qbG^H2s@ zK0oN?jGU}1`Y|I#EPs8V06{Unb6fReoR1@9oT>7iW)K2PBWP7p1LrP-e7jzEUca}C zcq@~@)>bW0487V2(;T_#3?vKv3bqg~Qg@jZ=;%3LOb#xqKQ$2@)72S>1bF?w!D8@K zM@27WnrACn5TMT{g|bUjIjV_19aSBCFbnMLgXXQfHyt6g0O14;fb%A6q zy6wnksFXYUv0@s`-~WkI0BM+soVM&*wTEZdB6fTfb?5}&Ip9nP>Wyy>%d2QbCzul0 zTcSgp_~y$rG&MEpF1M6BJ3GNRFaJHQIaPNcm@7Bi4W)2LHs7o=3!F_EVo)iM*8sr+ z@VrO9>a6Xb<^Vod)j)iNhK~LV*tmwT*aHS9s6F&x=V)n{&Z#b^^XU3*>#uY$t~RzxQT$+I{bF7NA%pV2!F7%FBeW~!(({=)rl zi1fil&JLicJ$v9EU4o2^On>`Qk5wh4dzqY$PSP76QzVEFAo1V;{!`#cWw%*!8CGe& zx17v+Z;i-nPZB^i+qt!TRu)Ur4S^@)9Q)mx?d&;?kUWdJ z+cj83u06edN-*wb8{VS^lmtHBMP$>qzLc}s)w|dlttL;eKay9uLp9ntS(29p{6Ks_%S7Qga&p7KH;w_m}XWHXVpN#kX%>3bcz5{g?^#bkzH;8)w zlAQ}WQb+4oE16zFH4m7R@Qsv%rJ)m~E$u1XJZASke(J$`m~ zWN^Verczca0yxf8@ z%NpEzKenrb&8ynDnXW|3j%0@W8rO3vgkoY>;i~H)AiV&jb(gElnp?99y+VqwDy?V> zTQTj7%IYe|c?5M%gOlE4^GPGHY_2raQm>y8w*#HKp@Ga|N?!U02+iCrFlYb}zn!8y z!~@pH1p71M1#%O~aMlw(6z4kG4|S#q+z|kF!3iEuY>FuU6f9te0yG&+%zy4j)LY0D z;mDR`k{bG(KL6y&6<)X3Ut-aiM+Q39iAzsGDJd!1*RQjF{`}dMA2*W>#y1v)IgoL4 z)|6Mxt+^0iX@H!AYK@2Z1-6)3n01I!WzI$6`z9vvJD`f0p<@l$pp`SR6t~x*89@#n zr_y_{IAj9zuF6^VGv$L`_4iD zG~#ex;^?yLGPX9YikjGLTyt0jI%76M02@DC?E_BS1My_R6&-BF*Fom9K%Wo~@aY$8 z+L?_eLgX#x^(d|IfTnKqpjVOy&0Jgd?NGjx&-mqklGG#HQ!ST@=7VPl%X$}#2rz0Q zhnyzO^(MLS<+el-!QV%mS`IGDXpi9vc#N6F2DZi-w%2ArR5`Y_pKP!;&WAC%b1jtw z5Gb_42DUf<%?SN|>(V^KrS0w{F>Swa$vBV_rKyRiP2SN{=A3o6ksQ$n{!O;6Qp*5NFxw;nJp43M-6T5H@Y2K1tyFt<`uvh{`cLbk~8T-Aorh42W`Y= zQhRa}Cn8d72R_zgX}p1-agd{t;bk?OC*a`6BzjMrB3Sa)h29q>D{XHhAFSp)xpA3y z!R86wj2cT?h-}WESuY&&_y9Y*JI*-*Zni*H)eo3#91K6HSw3=0Y&>%Co~!Z&qQ^`9 zFqsYB
zh?`@pddS3&8Y1h~eqQ7CBj8BtD~sZT+c)H!z1B)LmqDeay^nA1K4bs%%A?Sg=W!z) z0!JV&`M=u?kHSpwQv%QN`3_rc&$o4-odC-x@76J9;esq5&38uFcf5FH-bUkvy4oy=tJ3c32+rOO;R7 z14U&N=E38Sav~KbtRq$97gB8LMK8;*Tz>M>aVZ}#e( z6VOfZ`3(Bj*bAO{yt?GF_J@IO63AmZJYRCkb!jYw4h4RHb#jQ@d6sPM3L$6Y#g+Rv zn4fY2lXP{jH%4dOv)yT|Lc3OvE7>h8S|x3xV{3ODAAh33saKF;y)aLbDWe_E3T4|k_|y~ z&x};FQ6l!OfQ6S}kQ=#hj@h7q52T`4j@f$4mi^qM@TOZwDo-cBq!hjr@sXxkJcW?r z>ukiz2>-9ad;!2Vh;1bHzzmM+!F2&PB-X2!9a>4)SpiLO+v-+!f-V6Za@(tr$b$)z zs_JMV*HyTUbRcY@WM{+`yzo{xVvZ|1SIW%?fo%BhiaC=5qWb5DW)iAmi{XLP-i6L& z!t1OW?@Y5w&w}Ry*!gLA3cK&?93LI36ui0uxJWce#d*4OL-!W6k1u$3XTQtq4!1|*=foq)PW#WZ!5Geh5iFArQ3HV@Rf zs{7`wto+*=hzESvYp`VDDveFoimmwMF|NVMr4`kKbEv*T0%ZBREGe8$A%I*5Da*2< zs3UF$4-z21Ew>9lUn!m1bOtYy7qA+wnn#Za(fFhRVyvi(hN2rEQy_>kHtDW&J%1wv z1b`bf*y6$BpWVXcB;x$U5vEx+py&2w3bB zodMBlln2c%feEqR-*tz`D8j)$##!dfywL7=@-)u@lkb%Naoc|3Up)4??%XEmJ3|D$ z;mBwKC3=f$(6FM+j^~?@+u2w|%>#r#)(-VHRqtGZ^z_@-ql=b(A?ftm2r%(RUkLEQgVpA9m6=)(?qZtUly(z@-=nZFq@S*$LZkfwUuhUX` z;_fgI?)AJmzaMU%3L`qAI(O!K3gc@p9p9MSQNoX>NQL`zFHCJ;4a}m9ShwGCHC=1| zzJ!QKt}7(}O{&pBlUP)QVMg@z+Fcf;*#17G^;*}U8%srK5FO+&sOIYn{FGo$CBWqK zaZ`4{0mKW7<^rB%qDeOx(K))u*WV)&Qp|jd_p5t)ZsK-=05Mg=a?p697!58+{Td1JbHxb1#GM9lk6CKf#AaZqK#ZuGn z0o|L325%?(ZrGiDfBEQRc&l2B%Bx;Czp9yS)HN?^4fd%_F%~!zQ1j->l=q-nn5;v_ zBfrdZv`u)}Fbfv@^5-wD$G)uJ9A>OjTd=Gy+8;)E>L%h0Dkzv9qVOsT`$lp|5{qtK zVxsC>#yD)Ms&(Pr?^CLx5Rm(k)8TdA&(oiomfWv)Vas8DqX(BfH{F^B5j!wkJj^FG z^?fJ$HJwDsUINo){3q~@K*&T9w@m!d_Z5mh>{BbIm`rB!@7m-}QEzwGyTqDd_j(zXPfDC0|?8SM2k#K)ZDK{`*Y&229(vA2+WXJm!ziS|0${Q?8ZcV+-1U@;i zrZjAR-Od?Q{r>h&q^5Iy_^wlZ^6sDQ`q|^bem@D7rFUm*`)o1lr2UrcdUcs8Ii+hy zJ>j4qrr5TuS?%z0LM}Y(3%o8x#?FcpbF2NcD{t&?r9f1c|C_0>{ovXSZRXk;*Pii3 zx#R-O!Oh#viboFIJ#=)#i)MXzVJ+`Go+z#v1n|1Uf(T6Qp3KeHrP=1DdQ$d0%x-XmbmzdJQIdN8r#tnjfHSW>m`^ePKHpV3M-FpzUb7N#M?=&Jd z<-)u-wV6NxRWf0W&!9{cp4_Vu4oUw&yix6U#k}r;ug!*9rZZ zR8IID9MfZ&#Wj7MYw<*EK`V~iPTP}3yhrE2{cwEB`#H(0YR86V$f;JYrNN*06|fS3 z9O-uD)e#f5eJv~HrwdG~-CL&)Y^J%t@Y^vW#wuBuE`O@!8%qzcjAS%Y1?1>RtX2QH zHFC(cM|LvXwN2T#{L=yMAjP(x!rtYsv*dCVuc|9jNhag0jT#1pc8?yxg&=@4PMtMg znz)~fKlX9d+2hWK{y^D6lxzw ztj}CAIQw7fpMN)?16*kfdiw`**S^8@wgdqnpjYPuv#4C1n$r~!(^7J<-lWSDX$?NT z6WHBqx;auX%5%z&NBhARc+2$if*V@F!q2LsEw=mXKWRw??CuC3Zpwq^ufCU?hi3u+ zh6VsCINbN`;QkU3-zNvc6Iddhet)#-4S)v+Z1ka17@5$kE0n&9+!Sru2^VNq_OF736vo1Fzx~y< zEH>-`u(7_3$%cngB!1kDYQ0=LKEqDq!MFzHfTQI`o+V!;{MUERrQq`>xn(jXX}%Fc z;gswQ!?fJAlFcB4TwH%{IOyO*1(TVpRrw<7n2k4b26s<51ko zxTheY^GHjq7>uh`DtAl|szPE+JA2IToqFwgoPnfh)mp>jYj#+)Nb%~N`@zB{@-EZY zVSLJzy5W7t^qGP}S6;nk$zuHn#@ZSVL(V{ls5(N|v6D$VZTaKLnK6MymecAmgLSpG z@2zxBU5X*Q{qFNIil6~8=-=3_!(6nUSk=dVv~{)~LHQ&a0l>kT+?g5eFpPvaYtkT} z-33VcGd;G)JN@Ct5$BHFrgbm5* zi>5L1Qw;P&eR9Qkt=elho>FBMTo2cSUM*yDFeyLAvHw%JlkFs)(|jHtd+u?{?x^9f zh7g^i2whH9!9t-T$^pL=N^jol)jL`hWVefM@%?tlNbi3G45Udf%j2uJGt*3{IvwxG z(D37T6CVmtGDZ1eS#ZOBiQ9iU{Ds}!3lV@w4gkkG=k5*VFV^?L84Uq!SO4Q@3t^?= z;RS@tO86iaO+`czwtOmI2g{_1ru{8b)R$J_y8#Gw~rDJ>nop1-Q08DzIp| zZz&?GLp(NtXvKs8u)YN1GfpA0E4S6kv)3Srl@JV!Yancc-JtQKOeNbDs%(=H^bk@m&@;;W_g)_n9>FaeFK#StmPSr|Q{B$1BBj~_Wv zcCU`%7VEl=ejpt*Is?)Ip8G&$E`feG!Y%qa*T;gq_w{`h%%swqM;)=9`g&DzAcCma z+PcSlu{yqw~- z2NmXT6#boO*#a#891*v&E1nGejv+bEvpfwXi6 zq_A)fokZ_9o3AcY*JfLrqAhIQ_^$RSGiXsByGX^1leecowlw!B7M(F)*-25fh3pO) z6y1^i@$!@XMhTKvYoCY*Dln4-m7g7pvwO-1McTN-*a8T(B|=9RI-TQ_?RO8CGlpp; z=A*w-jLz7U+O_=b929c9EzvE_QcS8@VqJ<7nA1Bvtnx?~ahsdj-yl9t=bob2tV50t zPw#e0($BVBv^3eECYTZLpA)AR4AZ34SWwI&n8{a0pGN%S>jC=q!37mEG8QDWMy}qI z@?h;!n?$<1oSd8}I6rj{P0*@zDa2GT#0=Re`UAkGxmx@m0AnL z`2>kupL~1;m%Z?)-*>I0YoB6oT5?#R&_4Fbs+?A1QOSAGdpwP1L>i>l;y14wCZsx6 z370vJ#7AJ0XnBlQmQ>Q^Dt&%fl5Bg-xk!`gd8~FUtTA2Ol-3-cJoFTzd6+x%X)2OdWH4##!lm3AVkWcig362L zrjf_ljp>fYkFy^(o`2c2WRsees#QL(*7Ln_Kxm-%dFB;7vKM4uzi9#sVfI((!Ab6X*wQc7e{V*H{W&@(h8bYj4S3XKGf8C zHC~OA)Z#jI?bXKe5|zM;QI%M(id3#Ft2~L{)5+2Jo>NJ>LU_M0U)A>tJw`=_N>#nr zF@=~)x98g*=<2hT?D>|QtCI1BC3f(R;+Uv0H0~BZj0JL!Uqz|he>%)*BZ9H=%I|VhV@&Q&E3rq44eUU129V= zvaEykOQ{3~9Qac0H!7n#mUm87Q(F|P1J9!KiF2=sDrl z2}nhCCw5$khps>}S4APx!Ls)kj%*vUrz+8tEwg!P=V~f@x$kz5GF1*zRCw-q?;jI*=6mM1)J86m_eeuz?p+R5iJ^;>GMx&S!On< zorrTivzORpsQ8P*2bbh5TpS|Zo5w$;cQX$bJxhPq%R~vsdug|gocsF)Z2Ru|H|Sj` zie9;)%y-eL^IEXaq_$I}kWlr)$)5dV?|SW$uYlc1t4D}%`j%_g{PR)Txz3%ok)5l7X9%5#j0$HCw_8}+ zs& z)1j|AnMhnd3@}In$|sRK49m?CSc$ z{t$Z&!AK%2fVE7#SJN>nt7PH=kapFCV`RTS7l&-x9teF*0tj9^)DC*@`5#2;K13iT z-P|6uWD*h*68_yy{q^hD{r*;uBv_xb>ZALgqLD|=*ymX2$qX2!Uu6*4^D6^`k!j~+ zOw}Vh}2gscf_p}#}w-yFd zM{^YWiYqOE4YliOd)mt9w)$x2cBMg@MOr5sqn5?Iv0nCepr}~NSSzM6jY&~SGj(Xw zOdm%DO^kn1-+Xl9?;Xso9hx@rdaUoZi3%x(Dhw)QA7tQ&)TwEw7nQQ2EMdb^TU#Sn zGzVN`V}qfQ@>5~Y+f9Hsr#~7mro;0nZr8Cz$I<&w-JhJir;H+ zS#YXx`-i8toZ$ajSDSuRTr%6R7Yi2p=Hbkuj3_%uP>o<8b9z!M*S&1=k$qE7hP|a8QGIv9iP7|89~hI;wkGBa*;oDzzuQtWZqOLRO+<+udM( zFmp+fsd$CS(w3=8e=dJCp0+uS&V606HWn zLjqrdk(v3v&KIJbaJFFzZT(s6YSvALZ=RGm4spD?SNCuhA6wRP-TOiwc%Z%klUg=8 zgj7EI{vOL51LIN|k1FlCxw&=BAvv$bH|!t2eRLDMjzUC^oDNExKCyKE`4zSW;UaV% zf_2+vz$CGQB-URGTP9E)p`3Xn8bv!lW&JG4J8Tzb0Woh5RXx>SI9m|lk^(X1-QC?T zKnvSU*FJm(Iu4*1q;+L$ivTgqmRw-7di{uKaxP*ip+V?AZl>`}Jz;vW?nEyP3DVbLbfjql|`r{gxMW}c(cr;++I*&dpqT=qYci#>-`^D&-kOr|@tZwoIfykL)Q){zu5Ip-saeZI|ybH2X2 z!3E+wxF5$``Nq;TkghZRJjyc9HumlJ$HF!{%HFQsye^SuY0;KW!)mcHBuY(Tc7Lp| z?(4mmugi@7%za2aPKJFcq&Kr-+^Y!-?B=lS`sw#HVhQU@xClK(`Fq&m{bC^+^n@X)TT7=an`WxH7DC z+>VPX6Hh+r^5s?TVqQV9#`vZw{~4#owy=RHO$jy4?GH!!M=WsP3RbWY+b#4g#a3|| z85%;xVOzsh{W)pRRDPqf`LbzRdMdw_O1>f=rSPBUG&=FEM_Fg8ssHm@4(tIUxFnor z1bgf?Y0tyay@G*36U`D7$qDqe>(}Sutf9;TB61UxkuHj#AVNqpImW{!xz!z<%4e&N z7OFNU0@%{>@?PNL;zq!E#Z612-NO~6HrV%?XL%GNS>PC~BhnI;3dE+=q9UdK{(fK% z79>f1y_F4p(g;wPGy!LaP^8X}cjW~RwGec^#XME%5;!vNo2DQeraP2{iQA}g%GbRm zod%Wpiurn#|L^^%$gP+yONme!GCrlR#$-_f+xehZ&thWG%5WWVdW<1ART!!a(b6b? zK(oTsyMnTEjZCM?94%Gl6+Sd|cWorzANciIJQT&b^ei*8FD0Gk_&dsJLdw4NcV}A1 zmWxX_lYr5mYf(JA3A-BsDynRQu7wnWB*ulVptTcjqI)k>V+9T_m(}$5^ z)YH)e49kAwoBl?Em-D0PTHV;{wsc$ML+{4i3JX-Uq*2sS&T{D=v_R#{+MC+>l_#^8 zO-9!}(!JUNT$gcmr2lA9P9^4r7pzWT>4Zl{QXFdvUOs0v#YFjn`MSLA#yB2vWJCmj zcj2-Ae-COoF-%3r29%e;HA^kuFbMb%Dd4qjU}%_@knk9K*omU+QS2sNcO*I@xJ?P1 zr52Fw(P3d>?;{Y&<@QtY121;w%%{#8ZgrUJX9ptPn{0Pxhd#)28;_k&OX*K6*E)

gR;w^bjHr7Uw7~m}Q%ts#W@O*@QF{(bXv%-r8bfZf*&5FFswtWZ`;SYa zo7)y>SlTtSg%e0nTMS#je~HZIIv+7NQ@elq^f{YK^D;ylCz~w%|Df`JkIO+X!%?|MYYoQ z;?}5<8_Y{+B(*~Jx6_&&YqdPwX2bcSt=X!vREosUb`s})9HHkH$HN9M9Sj}S9(U%{ zdXOf;gs9l zgOOCJht7*Xb741D&D?bg@^EXhUmFZ7a-?bV9}N*94tlDnq~y@256p)lQ=4TMWofIudayWsEA8fx$K=1%AY4@DH~r| zFCp|u{Myqx=`4BV@I#+4F_krCE8Bxvg51MU1UK)%S4bH4*$hlp+sB(k+&e=Hf_AJ7 zCREh}$Nnnf-+JyxtGUIoIuu@dLd}SNNur7U>x$VH@9Bd0ZGT=75tFZJi4r8f@RnfD zNk)d?nAt0#y?r!X=lU_~R$*I=)#>EvFRl7?g2~(Tp>?^xX=2dTxpf1c@z%`3A-`gU z%4`q;l2WG-R@dU%Q=sAxEDjDn&z$M4_}(cgW5W5|K1Y9tyN7bK&mlnKgN_1x@BbFJ zSJZDl<=d)#Se8a%YTw|Y%z<-t+{*deJ+N7$4AZ1jkn;D5^YdH@ouA_#%BOX% z|3^50_3#&X-&d9a72>u;uQqsLRUPf~Uk|#Fryl_cZzQw*=j{I3Yz``ehTY9f|~>#UM(R z9xcSvtRI_8 zeSRZvB>oWQ5LKS<{HbFy+i@zR(sjoKCepQ~*5!p+(^CkYc&L-G*{D(p7i~qiM^T|n zLfW5*a+ke&^|{jHmL|{9efVF6{(eO9?=QAe+HCFER~0T0D#l*V-$*)D&SAzX-?0oh)?g&JzZN!4;z^Y*? zo$=2(_m7|EI5n=cS^puW=kE0_u-5%bXWZ3gw~ohm$@G#A+dh%ZgWywG2*>Z>6n5+( zCzHa5e-#&jAYdasZ;Tna2bhJ4wrC+eA$9h*2N=tpYXJ>i8YKRfIKSRe)+MNLCF#)a z;e7MwKAS`GpBBQ|vpEQac#aNV!g|Cr^E-oa!&{hZ=g$1GDaBq#4_P$*NTo6LmXNRr z3ZL#7Y^VDA@c;!72h{)B!GwSPdN(9_lAjLE{ws1?u2pwry??{UR zSdLe?A~dF7TGan+nxn<{j*26Ks;u4>xz_K)WwCl=ZE&wUtc+ypu#=AV!g_=3@vYf* zQLhKFqS<1>x}osMkcn@*JP0G*pi_%a{=>bRTGNN<0k9z(7P_)!<<_Uhrl#(pA-BFs zzVSC(zlH^xPNx`!^6V#%M@ZoK`S+Q zNxRU$UT+}ZD8yxFnWcEpJoer{FZ7MIz)L!Zz#i-*R@V4##W%D(WA{e|fsit$Dr3wX zNgcMw3|!LO`n8Zc!vA*NRo#M9MP6RNM9me~`{9#YSDY)g_S~q-S!m0YxX2Y@F1|?< zQm<$7>J^x$E1gdNC%TOIEwPK}8L4#L+R8?1j$5e+xoo!aR_#d#QS!eSE~7fKF$Mnk z`LxRj&tU$pHbq?rLgK0GMPy|HJ+JX^nrpqdDM8Ma`uTwDmrzF6&=5A@rI4&*8*?~6 zQdwT1wfxP@+lntkg&DRJ$olft>NMtmYIC0FO@V>2R^#2-yzl9UPiQ&1SB(8F8SD)cvQWv8M_(s z>*vqeK2w1^XmMe!DzQCpo5}xem1B|C#N=ej(EaVD$XR(DM4PVkcf)XZ8`ql3EycC( z>011IjE&r!c7-CA5n}F7ci0_{^ixw)=W;@Ld3f%58u$$jX)FyCF2sgFJK8w2(APcs z?V~;&4LthoZRa&}L`y9>PG7*$8>096C%e)um^X@tsmc=EjYD8@{a(vEPt(qRi& zZfSRl8AO%OzyI_pX?l9PPME+Ia{Q)dW|+u>7_SHXT5@QbviZt-@t;pGUh?MncK^cG ze7hJ;HxSvkN?YA!LWH)zU_+l~;U42#gjZb=4QqxidrDNfrXi=OGG9Ja!A(zguX8DD z$0{#BpOD{k%BHj=FEkP2bmXdh!fXQn5Sd*v9V`zIP0$Uy_mgIfJ%huua#oh_6#rQ) zazwX|hut!aU$S%C&B<i*lOqn;)&Ja@m@qHcm;aZjHDkWv~TQ*#Tk zS%>oON>@kc%3m9{57B&gVIj3!DQvlt|L+Tl^-yvelraT%a~-tQ#qJmf^HJ1c4jMS| z{OB}hco}PfxVRaGtWc_o=AQ0PQB5P+6bcOrVKA*m)&qt;0znpDc zSi5HjJLBJ9;^(k?n?y3||FQNa;8d^O`}mg1Numjnpj5oONMgiXjiQ#oWN^GuUc zA-hmy$dGx;lx-}PqRdmqWS-~Q=D!{%=hXLo?eqSw>%Xtlb&&S(`8>~h*1GR|-75#O z{jDkx1_ANRUg(-px@tH3lz~9WfXO2^7=bE30PR}?v|5)$YZeyy1sV7U(z7_jO z1-l|STFm3!8uyZ=671p$X(|fMFB3w)33ov+mFjZ`Aab0uvt2};e1|x~I-ESp_m!Mv z+vyWeh!81|StfB{y_BX%MXJ{L`DK*u5hP|ZTQeStOAv5lC< z#N-^EXyp^C1a$9pv)hhIb)b8nXKp(fNZY;{zeB(i$C9Eckk3u>O!nkpE&}MXx+TYY zy19K>Wi=H9)P8!jy|6eXUB8S@c&1Tr!F^?P#R#hFAR+saG)_9qmAikP;_*iDPq6c6 zbf!mjqF^=98gM~a(l4$;F1%-p&j2F>MZ+^?gXmy_ie9)e%!=S z*HLF}bzaQAx5mK(${P8K`V?^EpEN6OQg3A-{Bx9I)Z*Y`L+F?;(_L18UWg>~{Am>; zwAfaHi2fD)Kn4%D6YX2u(Kcqv%i-7z?VhTHNrbL~e(z6(IU3DebTph@6WitDfmoYBq)q&8t}UIx7qgsczN^MteO zv5__j*9oU%qUn3jt1|C4_lb3B)xjx@?r80nncS27N&Q;dGr$hv!eZm}kDl57$ER*9 zhU72maAPR>B$U$J!mww)J9WMMw5=0Hj6U8xk=P}rN*6kZOUmJL zqMVNM?m3jxQEuJ3O{x15;^v8?~EibF(cKIb@k@52)-hHj>JE-Y6&~ z61YWdXvd>3*`N3I^>O>w|$MakVuUg(-u znx*ngzO;o;BEv*66B~EMGC9wPrI=RW{J!l71U^nD)Yl2j2%INdl1p=7Ej5X{GaNki znbawZJ`I1Y>oy(;?NH(B2fs{sJ5N-8s`{zY{}8N)()uAkECjC)oTerBp%k~X%Q7}L zre1J>FIvp)ZzWi9SW!KJU$ire2kMrsAGeITw{GfxlMD{XmNrjgf8}8%^=Vn2jlo!4 ze}Tv5-dR2|nFn$cqCaHc80zZk`ZR2p0Dw45CBb)%J3I{uNrE9l#dQK( zA(C=rOW$O2CmF+vFm#xxH<02TOGs8Ss*~mM+(VG!HF_@M&7V*ROM#u8T^hJe%6r97 zMoi4ROr57k$HIx4n6pouO0voLx2{|9QC%Sud*B7d2S_ELbb=RnM5C=gM}Ca$sEWLf zoNdJ?9<4)HXadU)h55du`sWT)l%V(I0g{3IZSc0@VG;cEmmYeb9*`x%3`b*ViGZZN z?(zQfr)~N!*Bl}ft2SXK80S|jL)wmi+99WkJDUcpLLvJRQ?c?7!@IY447cjA;|AJ9 zHFXM0EH8?c_~|{%a9`&3G{B1%T~*V=IRDq8{3NdjU_evB?Mvd&Mpu|+(azHD4y|X# zBP}`jD3Cr6UKd?_UM{-gLhO?e@PKyng!*f2K}EL}49(0G7`d}cw(_-p znUcKxTexgY=LRd4jg;>~j#tM-ErsW<;YNG_+8jE9=T&99t#Z36raorh{PO}7qh-T* z&X6TC*$?Yw=C}RG5>woXgU~JHWo2z`3!i!*;vq+M*z(koR=34rGoQic>=ar4j*UrL zISSA%UI)F(GEcW+>d}4IE|k-U#NBe`Wx@{@SEZ+#pDL9fNS00Yzpw`uD~>&L&5V4eOvjHvKp_9>D(PbtD)21yY^^3JULcOv3BQUbBvGv!Z9 z?8n%pHt-%SIcc=Ar2pzVz<2AfO368l%AlAs_&eqLaGp5fj|G?^vdnk`rY54wa7juv zjG4grv|SfK0V?f|@N+U(&D5K?Th4&k#?i&Cn3jwg&2D>VAPM~*kGXzjJ@96K2e}#?Fof;^&8gN=H&sN# z2OL+AfPO4-_)PdHhzvlyOHTHDtpRBfJB|Pyee;|JWZ0yixoPY-T2?|S@XGS$FVVhL zVMLi&RQ$riLa|6&Wo0-BoXju&@iQcPM8iTpghOffRL4Em0sF$mkWjY;BVnja#!Jvl zDYQ?=Q;_UL6DgZ+9wXyd20rxGrma(p_g2DvdCe|sYv;qHPad1G?XI0B4TjJdEJ36w zhbt74>8zvz_l+lacbmm34gsa|YPo{V)ao9PggIIN9OY`IpD+Q~ufcqB&?Q<`;Wl$B zNhg27qXe2g;&ZHf2OjD!Mcf>~@XN!i-*$JnY~Svl693M&G)UkQgQ6$f!J3rIu9wS1 zTjCm0aupogj<;I2P7`K=OoXQHmBUr*={Y=X+7{Q_z;T~OyIa32L#zK-b_@hK!ocwU zi%3{9YJ|t?&;0SJv)gx4QUroa=4V~;0KcWoUgJWo9eetiNWm_3ki>udC=L~{w%s>< z!qDro3;<%_6KFs+Tv}RMJ=RgoW7?Q{A#iii+o0g!={l2T>69e(h^4{9hGSkIta7x? zd_FTY$fcEiOy~(QBTT;)n(k?SI5l?LT}eZuX}>PtPVd-)m$G$>Gx2dbxHLnhWw8tD zMhuYUTSc>bCRh5O^S)~4kgkM3jW+3Pq9^+XK_6I!m-W<|+gjOh0|8;zvF>mf1Fk33 z)d{Oii%8jrCH0}EkkTj7SAHq#ZH^|kwV?HaF#Qh^*=8N0i>Vqd-L@hCaN;tN^!PTk#WC%9quUqI85 zg{K&W7PJiD8d|;YfFiJ(I53-TJvERWR?oUTMxpKcwGLRxq}Kyd%C$qFEP*_$?3282 zp38>_GRM3VtZ^bKDP?V2$JB3{7Jk&$I)J0ls@o<>2lsu`Ya-(fyOp^tHc0A0O9K~7 z@#G#T@Kv0g2r%g##4VP)*NY>@D6-1S`(cen+w#3ccszFR_7=CDCC(mOA#53DDKPHF z%_fxa9O;4E}X*b7cDsBl^~)?%~DKhv^otZm<&9axM|lx&jZ*Eq+)^0 z^IJD=0Qn$5@l5yDDFy^}-%+6U-D81e(a=AoUS*i^)hr+LT}>0tJlHFYF4ua^t%G<9 z!b*!RjLRrkje2^^AJS+eNt>7i+)o4r?xB)0&N=4*AC>2P_|NMu$O&-?f?`dbs%{c$ zSS0kn3PviR2m6m>w0mZ0R?e!Qy2or4!O!&YC`fJ>Hnk|!Zz`THIl|IVApgADhn$AZ ztkLyR{3byTMz8p^o+qL|y63uQQePcF_yc57uel?;Xe{?=);|NGC!nG1jKrxhVF2B` zC+DArg;gYp`{7Od<-QyK7x<4NwLV!p0a}pEw?x3j8qH=t&p5R2SW(pn4;*jNC^fb${Y? ziVN_7wz6p(zA@+49lk;iXUnZ^a11JN;7W;F)KHUs^yE2A_s|HMam_qaG(c$uTRl#~ zmt{-J){|d7mpA&*{RirXb-@gJ9SN;8%WkDxS-4FjQc1z;V)?$e)oCyZUwdr@)fE{u{>{`ThVWQWGRvsZiq)T;v&457DSg|e4r9PQ$RzRsKn1Z z_4!!GiXG?nKaTS8Wh+*Yd-y=?QHm9DP)ga6L{l1kqmO;cwf>nox$l6Bm_!t0PX-3k zJ$Xhggw3D5AXsEv3iFih--{|V-bAjKm;3IQob^yhi^4H6IkLxi?-*<#k5(0{nq zrbfm~exz z+%iXdB6_k!5X*-SZgbToRtN17a(5cc;}%mjJpeU#_}*ad3OkR9Nq;GNGxFkputH0B z$oQN13hcaf3P+?qaO`z|FB(tK=7`Q|)|o#*=N;#`EZ!Zy1d0yMe>n3ofmAv8p z-gy6_o^diwT#g8kOa=s|a78EsA6(R<CKHu$j<$t`T{f|#Iff&)fo)GG3bN&wCwZEUB_hgnN_iJPKzsL z6OwwDb5~L?WxEej3=_=$xN^?gRnkPql;I@P>RQzjCi{GkDJB$7UrFqN?B)}`B}2rK zU|`?gq$=T`PUjqlGU3q)lS{x>z#Byg8}+?IIM5A3tb_0(ZoJ`AdNf?@v;T3iKWUYL_}Ur_XB7E(b{Kp&xzH}B~visV<-`VJ;9>6Yd$PA`5F7-oF; z;PmF{abMlq=aF*tJPle;JdOdaT>xnw=u@Gy?BgsPo88^rp9guC+dVO2fv{#33=CcY z7lvhE75j;9S`NK|37+p#EV2MI3bAW40%*z)OS^k_AJj0HWo3P!aGqLT98CZj82;&# zCy^H)(sA9XlLcK19-E||-t)L5!>!%g%4(;=t}C~DdJJ5Ln-Zo520oM)8r-syJ*IN` z>c!c`KWuWfCdv|mx|=f=Y07NsZ^ZP;(VU6O$T)r5zxcDyhu#~fcPd)DUd9878Q5Gr zZ1ULU(+y(~)Wm};yY9TcJKQv27Q{VMBmADtD2@1Gr;iSuT`k&i97~H2YR>^@_aaD7 z#Mf`=MN_aObqTjSh!#qyO~`#VyH{8T%zUY}3GiF_vIG_wDl#(Oz`G?O?J$y_TRW$~ zeMJgQq%#I$&lY0dw|)I%ZqE0s%yCEJ;sK-=(zW@2i#OKgNC~vrT*q>Kqky_2=L=K<#L1 zWE2M}e?M;pIR+CpaovH(#yD(__wgei-V%U?pou}*+S=MB3?ML2v78sC8wcvNK1p-w z!JEah&!6QXQMehe!L90|P=R>q4*Nsvk(0|$LQWguJt3b*CCv3O*R18GUQv^lH#aDl zX2CsZ06^*22&FJ>JSp>npQ|c*1Lg`W&!nMSRH&xvHnd`T_~9pJNz@8w8{s7zU9ATG?H_=f)EW^9KMsm zj1_H!L_np-V>hIR!W&$41cR6QvuA<_3hu;8^wOyNinHUH&;bA`Ml35rO z#0a7d0Gkh`9%J$;>GWz3VEX&wZ+Wy7)1w$8TW4J%9K)WHrvMSpY;*FAs*+Na1dHPZ z_$wI~Klt@j9Yw{OtE|2k4xmpm@3JL+ty;ri;yyO!iKeECZUVFr%LI^N#1tN0lu^Yn<(K#H1CwJUoCm~1V&!4FY((s%OB#dnq&w$2r!VIVO^J^5?SvwodCku!AHQ0NHW^Tvz-Q^tERU&eeBngi&BHBHh!@ zPeo5QpgX-EL^y-9M^offNW#?LWv8~ti4tvat0M61cDKdUk^(?ZoL~~lAzVy|okn28 z=D)8x`hwXHLOVz~Ou*6=plbs3ZcT%CH}ljR@HNMwLk-&xjyyGB)V~t|phCPh z9>vYJ+J_b&y^w! zifN@46@1!%XB4Y9lBwturv8P1^=bEI7X(NF;cD)(lB}J3`s%xlW~~ICf>B6!E5Qsw zSwZ1ixB^^GI@3Rl%Bm@G)kR+UT@_;t$$#|muU3rv&9v+Bg&i?bP#utlW1*vv#cl2? zL>(FM*!rrM-D34H%e*xna!;ho(10dd@S(SjTMG8MzRax|;R} z1~wS1R7Xo6>i1~gNzHOb4X>o5(>DEKt!Z!d_KHzJU|~Oa@H`%9k1B%|z%sG8T}Nb1 z(?xeer?88N?gmiE{qpdbk)Av8hlDrdAsig6frzjd+ZV2#YuE3us^f>12@%ZvIu05l zd=$7c9nO%21LABdSE8?KmX`MG_QrGbHM5J3rpNu=KqR^={7CcL_J51B>~M@DP#F>LA0E=1jQ{w6$HJ+UJ6e$YH4Tg^HPWu# zg?!NlEDw*YzE&@2RYgx8`5Dg7h-6PJ{7L#Qi6@)beLfV}mz3RB4bVp{fX3SxPfLP3 zPZjV!%}m*Ue%`r_Sx^Z@S7=J{Qh<;c#Q9x$+)G#{kc^2I_S35VcY5{sUKhB0M7(>o zItmwb+Nc@?#lp^61{I+p&u;y4KQWl_+i$YaLH^IfX6I}pgbA;^LGO+culQo)DsBkB zaizoWgn;w%Rcfh9b{D6s)@D*v{N`jRQ^a;Yyc5xso+<8}ReyBngCGuyzTNP~54?`M(6a6Q3TKA-WRR83jgXDe? zr%0U{ZjLEj>gGfY9?+=CAdZFcQMN&+uKQq(zysb1tVB@lz--@g<1oNIMN;Oz((0S~ z$IbRJ09V>I^X6&Q3DP@Z&)%X1Xf}(taGMNl^4g#041zb1y&vIROb>mns+ zPH1FELZ9X4W53-9kMq)yO@a;?B?~Hjr>LfO z6bF~IznTE+NgURblxX8YTP8&=rY!20uy0u96Rab1$hAu8hQH7VhE&(bbQ z0U{c)94-1hWSGZ^)!eR(JRv+gKcm;c`&X89wyfqFja-!%Lfy>1SO7aXOMG(4c$#+bfsxUDMp`n=wV#?=luQ z<-O4grVnJV-NUO*UCD;_8T@d3nPb?&ogSX+{yJRJtIdNkLG>xRkWADk1$RKnkos8K z%25k~DH5J^;kC>mUem32vTV@?fdn+$^aD$6GHE-wA_|sb_gr+Wt*1fLFUeO_;P?JhYbJ-#lI@{-O?=FG!RleG_DfAkWqSda{{pF$^&CIm3zdRbF%P3OK-*jwlwJhE?p3++$lF)3PE5p}fdAp}Pq`5AJ0#j6Vpd*%Xh8K=Q z0!7z2VVmkk8zx8}Kp8t7*2Q`1;6u`3%{?CS;CSe|7Y}h7hy(5rnh16oQw>ILie4~0 z8wdPuO*bY4j&_Auu0*rZq1r5aJ2-XMFYEgz*sr+Ot6vX^PA?Z^uZsEoMA74b1qqZ$ zBd%O~sjmRwqKg=3Y(Ka|*8x@5XrL>R zA;S90oxosBtD?!(AQUq4nyR@hrY{OEXh2Gr1m#Z;Ash?uy+U6az}!`ZUe%X9q;?FF z0;2>1j$!NftDQ>FGy-s0F0>>Ge4zYuaqT9)^?b~muMOPa@+$YYt{(HhRGzl|w4lir zYmh?PrOC}uVUPvbVvj&ph>jO4CqP?C$fMs9!c0MJ%Hr6y1A%@p9k3E6k0dWGL4^eE zU55gj54mq9Czmvbt)0`7JSRFvXyGWX!|>OWD&={umouSjjdFgd+V{2>nlx}AHxtcl zbMMhy5Wi$tcAWEn`gBk1|2b~HhV<>KudC|^xkx>fJEsACR@Q`2HOwrgVhliA7;4BTLD6u4-?fi3f){IQ10jS zb$Y++N%w~$&k8TXmuou*AuM!5m7be~pgTfrT@@;JiSCY&-Oyt!l7xR<)-MY{wgI;{ zZ>t8gV=V5xCsh@EF;%N5X9V;Mqln8KSKLd`8d>Cxdx9@^+^t(1pej^$baV`H%ZGB_ z2*~sJ*x1W}N1|%lu9f=+IAdvK82X{#2FC)!cXzf7fQ4_&;BPDau^N1Spq8FSk#7zK z?e{SpMN5F?*!Q>iC=f>ISuKO^KQ>N*zq1wurStc6Ou+7gu)HwH zN!3VBFg-U*+VUTF4nQVMf9{(JjQbULBrN0dUl1c!Iw9-mnIs zr80o<;my`CH8oxRU5D{&Ia(Yc5d4AUj{*t-Y4iFZdZ47P7msmz=9Rx=wMH#XC`Z4RB=%v#>aLr!?c>9nq|_;-V8}A>DYm{ z`QL$#2KkWmX(^2`kcuVLTU#`yzSK@p1)UY@enFinNFVDu{!kadDnHI^nUZ)2FO8N`3%$Ju+u|Ev!9s_`?@*&$oBCx>MO zhqwS`tsnZ9?)!NjbWVqGtpr=pxhE}Z`kMG!xDIL})gz!>aUgk`SyR+aa*C{+>5l7A zt-(gPskEJH6YZ9J%`UEQ+Q@t|^~N2Uyx%L?C?sR!*ji$*$kfOe9)4^HXBt49w2#UI zz}4-@HShn8LpMC#q)#u-&FkTBm^52;rk3xu?aDH_yo42a9reQuz9$vEH4whh@&V@; zuykZc)Q&BvJqJ_^7nGOgKste2ZBH@y%7|>0zzSa;&fvA(TN5MK02e(0a5vkTqzxE_ z`^}vS2D2V($P0WYZmvPH=)Ce3QxA@D5Sv}Tz7SA^=8ZEwDPjKphx#j3-x#-sg@nM` zg%!|y8fFDf_t!`OXj=UXa0jDkZFka7eDUJN!Ge1;OyvB}+j|2`2F)gUT*-EQ zV*Wp+%s++-cAm+E1%IqMf_53yAmGT2g;ra`%egwE zfKxujYP%DsQA|K#a$$M4u)T*L$9TrUa%p9j6?(@(h>8^Ncq|b0iQ16t{_;3k7Q}wV zHWTm+snr-voCqvD^L-qZ)OY~l@7U$Hfll`j4pfX3uF&9> ztc>m1q{Rw5T=o6*MfS(td}|Hob(=Cr>H?OS-DfV7#GC51$PyADyLAL2xfbHZbao*T zKzTXg>8CI!4OC^P;c7xSp%Gn5U2b1lbU$|Dgju>fY&v|@Jr*ZPP#v4Gn85io>NcCp zTY{^ujZ*}ncNB~<9EAO3H2DIg9B_Jrdk}xeo$^9?jF_<(uFCQf{P*BF$L2mP6ANLE|2aW;1dYF1o;);0$23%Yaf=+Vvq%!>#fN~$0afVh&JKhrd4 z4IKFh+;vxQX{ty0&wPcuP6?}f9z>35IFtxG8YDPicDKfO zJnb6KsY|lRTHCfIy(+R50K1Y(#)HU?uZpS;X6Ftr_rX978#t8)V4g!2qV<96LxLn= zdaD}K^k^1dtSqFgD1gX5I6*g4aAj!{b!Q91|1~m={eLRHAn8N1JrtpJ;H>VX=U($l zfNJ7VY1^D-h9VV=EsWU;Z3d4ke#2_7u&N%X>!f}{&Heu`a-siqwOT0wYlf+gy+T-A z@v9l--^oY`ivTISPjG-NN!vJHUEqV(U>^5@F< z5W#gf7*YbIbyEU>oL1|!im()v^TK>L@6q;5lfc85o`nO+`Ej-tt`n*hV7eid5+%l? z6&U7eR#m`zSKC4Xw083CWBE58BmtUG(UyiK|4Jsfz9$VeWI|1(tf3JkduIFsLZ5&Q zAq>PPWL$ZB3kg6xrhyiNp1$Wldr};UEYQ)#O!Af9>~aP6kUk!|l!1K?s;;T&Tnq+P z_*|#2G(Oa6fDNI*Wr3Wk(BO%D%OGm3vM#@bNd81fxzS;OuPt<6;av6&e~R6|S{FP3 zK?LGgpvlgpEzeO9O5n~#`{AbOExQgIg06Sx zIv+uRt=GyvER*tZ`R2M2Q?M8yU$-OdyUf&Z5t$$qN0?`x!8jfhh$CKXz>Z>fZ@HN1 zYz_VEy_Mz@o2ckF+H62j_+=$!p#hC7iHjt8pOY#SK%`p(^F15_60y|-+&o27eIB@6 z5>7C;$H@}mtm%U;l{!E-l>fr`0mSTs=IdAr{odg>~-R2Bf-x*()1QIL@>$_p_Xy4 zUUjnsf|NtBt(qKrX1XupNktF$TJi>gsO^UaAkY&nBc4XaE2;0Q~Go z;ffpbcQ&7mnaz159_a*T(l)KO4Y2XFpkFc*;6dXEqynJ>#;w;AeD_1o6TqVCZ{`;9 zt7?aErF;BIc9A2A5W7lZF0HDE3FOYy6yL7#IZfTY1PL;s&wy^^C)98s|v~P2L9g z?Q%N9B0N?^4t}`=)IT-eSz-eB60K<~D=SbLMWVagUFyH818QGco&{C)U#c@;>W^t> zYT}=&K4nV@o8Ac>C&f{g)))L)ho4|+w;jj+i)Q{f4u&?whdPf4XghZML(*w+3M7g! zt`rpuXr9B5hY*9eylLUs^xAbT%A?kEI6D;AKRZCD$)$M2!q~VP^bm2Vg&1sq72qI* zb{s&;B2Ja_pNIG#x8bHo+yuUQHI+1}PZa3lf*l5J#V=mmsomH(Q19<+EUkTUY4E0c zzD;u@`!ihQkZBU@s*Lz1wDG_EPxQP8++%=hs-SRi)Et z0G4*9|F5Q%bxh{=cj(z333GW3nMDM$qe)OnlA7vAW|crkSp;deoz4cEiJxooGqCWT z)@7a0O0kRN-AlU=W&>#}(x8CRo^1)UeINKbgDHkxM44sODj;8kxE2XgBz zaa&UBlGs`60tgT08pzX~`m@qh8594A^Zb>O|L0cy;tQI1r~TF#EWs);FOLK$2C4Z> zbQgn?2?NJKtqo|-@Z*Zb5&Oaf1mJkF|IUK>?1LZ)?S4&JW}3mSU^E5!$~}jVF{p>) zmq##c>Olx&mx1gNTFQeS7~8V|bYZh4`Qw{PH{%rSu6mx)$zD@afm@QDs`N7Vsn;Z} zyHKP641-D$uJ?|b%@ zU_6r7Gg@P=Kw1ee{wkOu5DNvVG#-iMfgoFXTw>mwC4+KRtkL`XI~H1cMFQ{dWWKtZ zaq*bcGfjXU4`{q(Wo6G9Z!onTES1QNPCl?aU6iOpFdOpU%ziGu;juBHYTP=!$h?2RjZRYShhwkSWl!4iabE8^G3Mr~L0VQ%11Rjw3nK z^_r+v8w56kAmKG^utqQ{B2<(>TLt~@GaSj=f4PAej9@bG{=kAvfsutY2nB#JHJP0R zc_KQFan;~jB=k3|ir_#Nhh4O$e+(8k*!RJoqmA3wG##7vzH1^Rp3@9e zCaLpBq6w(1r12jgG1SEMW{X&WzNjSe@Nb`(pDs_^^%gi1t+p4%gtjl_fz7I(3MhtT z{Q|mc#?v?B@Mj7*!V)$UHbPEkqz*c`dML|CpnB+q0q@0C18OTYy;R=|$mFn^KaeeY zZ?rf7Fb6VfCql0mJ|Z~8Fa?+HHc~s zfm|1i5)MaP`t?F!`nFpfkmUw{#8&qeB4MV zm#vZg7%*xOROF$L6jaW&@VPGpLJwaxfK(6eMBuT$6(<6N6SDWM+T}g`xOy{HvD(>+Y!v`73Fo(7DeWFpuB2pw>8wOFy;M@Ljbvkz^f2 z?tb;Rx8NcBgAyb}*c`RvhjF0y@nk0x^o3loeu>QT@H!Aep9?rU!HWkO-`1G?0bZrC z)(>bu&FuZzF@b(O8#auO+9ksGh?HK6JbNEBKK}DqdoNs2s~?J?{qWPI2pY(iJ1I$4 z?wj3S!)M@v$sH{^&(~A}U?cc%<>r50jcXt``1K*^rMVql=%9gH8Y`tG_^6KS(~A4b z{^|tfx?~)`)tG28oYSaf^KVWca>q&g1{t{gGY<1wd zat``3SC!-woAQn^hJZ4bg#Fss6F`W(z)f@dXgSv!3O3hL%3cbsLL`>^+}7VS9~)$} z_Uoe(*K!NaFD%*y5?k#6l#=aF07dQ3+z0K0ZCj-e;UfP9S^lXchb@>$Q}Wk6M%Zzz zA1Na$a`a}fTnOCzNcaBd%HaDNsZ*T$uO?)0`w+e_J+Zb=;5))>1?m0O^6K}6;PK~& zf4Y`eFN@7iw0`$_AqQ;78<4+Me$My*-5A>u_wNS8$bb!lI}Tn4S9PubAyeQqBfaET z9k8!K2T#6)9jN?}Z#EWS>|1AZNC$wwFXY8Yr)O(`H#GJg{S4uU#QON(F9_(wnr6yn zaSG7((Jgn~NCJgPw#Gg6S@!IpS2?T?8P58RRNrGLQ45A=WPF?`8 zFP{tiVQ=AE*0#%B%RoJ+5Xrpe?`>A+Zu)YKM}(FYu%aY={l$ORd4)egEgZi*Aeyo4 zg*BT7_B}!>!vWZ7<|mxGg3>~G8aR3(yO$CLbZ(Ax50MtxYS+aUiA;Ovq#5%RWh@iV z&%^_eS!LDQ1VTh8#Mt-iN$tt?Y=1_^5n_7h&n`X~@sq7;KvLcC*Q)#yI8q1gaBKU< zrC(>7;R)&Rb5aA8qrW^terfh!{qe7DI=b1a_*&-+_VXK#53j1Ehrry$c z03*{13nmlM^iR(#PnH)SrNl*r&&BFkf2*H`Aqg>4k=6xZG5k(XiOsY_Zr1pdk?Y#^ z1K5(*m-Ao4u!pP>6>oogf^OZSP(93vGBj=XBaGeY2d~cN=_Nisjs`Tr2<%kRXu*ea zb1xQb2V0k|-N)kVRZNv&b_r#=vtjAuEikmCu~=j@WH@1Z?1#G)ek6A)fSiTq?s+Gd z`NGV2oBVfG_dR!0S4Qhq$oqaVXLi!g&Ye^&YoG6xQ*LMpH#y>dwtx9F%zzx7GRXwv zd)0^8iI>l(j>qb5rJ(l~&%9QmLA*{p07Hsi8l6AVk5B7mnw%VpYmd?FS(yp<{mN+l z>Xk45@UyT}{;i82BaNwe20x~!@M!-+WnNX|Rm>y@KXXq}EVQto$hUHsfk zOLdQ$-wp=25awPEPL(cX^V}#J58^U@P-+9Z54gpogqp=?} zRbnl&brz~|z1j2av2EuGT`%HHW#+|UP?!bb7yHYAPRPixIO(i{nC@ng%QvzQ;Ht|8 z>Ed3c$-1Sae7ym-?5@3GqILIm4iT(pyvGxSWth3;d1P!Q&Q!qAsfEecF}9uGbQ|NX&hVzUu`osOl%<6%@uV!l#C`aexT8gX$~!v z^VPo5dv;Ku8m^B1Fblv0`vzlmD7^f8WNJEf^EKzyTQkxPvRTxo8aPCI!&!)@9V;Dh z3U34=O1+g8;&#jWehRkhTP8E5tw@V{hd*QW=nlC2W(+=A(tlJ+5{g7&3Uq?Os)jBr zRp7jZ#ck!wk9-~(!))2I1k-Iw3ap4T_%J5Rg!~yQYBV(_r!|x}W!Ng`^x|AP&umV+ z@rM!{VqrhE0}Sf^}fN4`am z}z)-%v;yLEct-iczpCltO7U*v0=mP}_wXJF)P zUmZ=m3YM{s^SY}p9?kvC2x#;pGnLU;+xw?Ap9ak=No6Jh#AxSfz+}gZt zW#O(8~{fQ7|k;$Yp3*wBdxa)btQVim@C~gxK?gwXAnM{#Bf;T;U395?g!t6>Jp# z)-`QGJ5Pw88F9nKFdWW(G(Sq)c2P;;aw2DlO@WTq?&>*si|abf8$OI9Oy{%@M3#P~lsHHjVYNPlGXjIFg1LLc#Hw&|>Oj44 zM79h!X`R3goQgtSJnKTgn>VL=?4BtLbWdSkJch-wK6Qbbr6Bh7Gfa2CPyu3eo^DoVJIvsV9WjHt@S)w4T;v-r6|`|;!pxhR3{h#raj+CV>6i3?n9Ff zZs7w7?#nk{*KkiqKjC=XFkHo{6BJXGe~;O+Ajw%O)1i!#I6iy?Ig4JW7E(v!bt`o8 zbkTr1U9GWvav#?}KNn`0Sv;3zc8P!x&xUil`gKF=M2m&(!1wVWktLDuW`%(5hdEh;Z!fyn%$Da7T8mW`%FGz);lQ}A zk{=-c0^TNpsaz zU$XNXA&$J)tW!&XU+a@T64E~6BzTNz%iy9MQK~H4J-kdVD0-LEwuX1BZ(dj#YPEOR zUAI6nHu_}-FT)x2xKcV+;ld8Gi$x>KDUF+Gj~~^U@6XhAF1+@Z(k_3-E-Wq`e8L~PyAl6osAtiUh2;ngq%8lIH+pGbO79oKk1bRDE!n#EM6Z>_ z9<~=RPGNedSiC5+neE*dk1Ocy94~2`zS=1)5F=`}Ghu7lErsol`tx&!-S*GYZ*aBc zE-)DKXlONBP-W4Mcs7r2bLDLkH|UMrb@2X%FXNt-yz-hmFOZ1`i)6@LsJc74=fLze zH-*zzn0CeOvVQXP{oFx?BRz-Nytx`hQZWx>nT29zCHY-AhS&r=+Gi8ridF>Oa!B7c znRWbp#32?%37u`2ll?xsX@)sPH(C_kV{gyhML)CmwzZYSRqMC1Eh~i#YHx<^&scY~ zdedE{RrQZhyrvRD+!COMkqGE8`BaqH_RRIe69<=(1oYZJ{ zGmaRHu!7LNfIxDOK(OIrzUVyuVOz=_gFeB8Udv}T;%A7Hd-qi7pS&i+^(<|S*Mov7AyTZn_nx9iW`dZzi=*y*#q^EPvGy3vqx zJHIrs;tgGzw|&&l#iY6|C%md6dcU(2&G*m67q96#dyDCOJDWFAc8@k4nL9}p($(kd>?e}-8E`V`B&zSe<-pAvJ) zT1qN9WoZ+3v|m&#=4m5Y@vcByXe>;*y>s9t{RT`uYvZjP&43MTj|MA`S$?j2;`^9mxz-?Hj<`sNRwjBh@9yfu@ucUIt_5|%eAHRI@r_$^KBIXdMPP`HtXeK?0j_=N zd_5N0p8eN#XfSU^x(crnZ=P^iic$5u;&oo?l0xjWL5CN?dkfC=czn@OBUm3A8FsX8 zTwZ1nPIFisjh*1%p_jolOVlWx-Afmts-gPgssG-rmAsZJwA9jUdZpe6CC)Xx2rTxI z6YN%?8fm2XT))x9wlm?Rq3@S^JNcD#OSswNDFU1wC7SJ?>K2sDLIT&to-Af1{K@Lp zl(ErKpQ2;Nd0$!GDMf$&(sO2X7P>dzGB4iw5OMSYe|CjoqEM>9kkeNq%}BdPMm!p( zf*IsO4`+WQpR%B$)g`Md3kYq#emeBUBIAR2sT-*g+YkA0w}?zMrEVzYz%ly;*KZSg zFzeYGW5DpKJGUYHll%j*)P_y{h<@ z+Qqta@Y5qXa?GQC$72fD@MG`ZWuL#RcTqWdZuXtM<=H~6JwY{vRVEw6oD3w?!i(9Z zMT!~*d)`Ix>FGL(Br~~?i7a*QegE7PQ#mvk8mn-9Q;P^+7Txg23{KX2R5=SC$BXF` zIVjmv1H)_8Z;GAtc=V=LupxH-#lVvtd)sspst$Y&cy!+XgKf$#dbf}uUiE1$(NiMZ z2B}RwvAFFY2K1L%&Nv_YSjfADf}L_~nWv&o-Sf3TwVvX;f-Hul>5&vRadvCv*a(b? z`$e*yU(#NVMz@br{K=pZLKhKx_GbP@mO`4sQT1JRZJS&xHhQEUh+-5EAq%8t^6jPn z@-c`Qd_yC!hkgkr716X1htv6!%Jw}Pu;c#mHLddg0TG>fT?ikW!U=|P;qe<|Y*^HK zmm)=Hdos3((y{pRNJlMaIBroH($-~Ov9hIpD#(t(+@0CEyD>p8tzb^hS2|A7!**qs z^UCLPQOo)8$&=Vz&M!`R9v=G)3vA<0`c;MGZJPq8@rZ{@~{D&bQ>dFR_MBmWf z=-G3LxU@s^HQ3MZTzb<_54ZD#sW^LR!B}#oqreu1M)kaGS+0k}V-3_P2~~>*NB+E` zdP66KNWs4Ke1pZ8AlKl>Padns#2Q`*eCu$vqggHA;-IHJ%bmQ}E^nwgQ~0MRCC&|J z(GQbhE-en+7@7>+zh7vV*z_Yoa_5jcxg2iCloG2BM(+w3Ro{x~3#q5Y1lhEfd#S%M z`Qq*E6B%b5pfu#)w26L7D7}2#M6TcucuOalIKJGd(`>1?JnL09dUHHhJx@!7Pr(dl z+Y~g_A^ojxH+c@RP0*}WgeHU0EOw3()3!WpE%}-%U&VJNae;-%?Zn%dqE~glBR#h6 zi<8aN&Zj(4#TSJz=gO+)qxWTGHZ=bd`D1QA<9&w2J6sI%)P#z0g$lg4IBKkXOaTibSD;_yhz?C;Yc%<)n)0R^l1)`>#<9t<@ z`7%!Yv0;i)`^mLLI5LEsaxM&ae4CIRsz`)@eh*zS0@x5{q*sdkm*-uS;Q#Ph@shw6wmPJNSUxrR4u( z>@C2mOuMkrO-qAxBa#9FB3&Xap>(%|G)hSeN{6B%k`fXEg0yslqJ(sp2-4kg)}HTt z=bt&}d^5u}%q3p9_kQ2^dDgnuz3%5;M0pfDFEdc)6BJVtlCqfF8D5C;2`N2@YTAE- zp(ndGkU-zUcQZ%IgLJKJ!)Iv5)MgqpvgzsPq&LVtl6N)#&2~PAP99%v7bDlmK8?ql zRfR-uk+J~KFaC&I9Zz@VH^jJdq;wVvJG$q@-Bh~?)=WQpN}ylrFuL(QOQ?C>bzszV zwnCRd%BZnVf+a_d7#C67FlYZtvDt`MJ?_^$kdY8nNN-$&@E z`ctTlr5!eQyzu>Y&s*V{rwPhb*{#gxGUxlECoAK+mX_y&$y4sOODm{8d=a!(5DJ$t zd-D2LT-3*o(F}X}_Y|MpW29_sml^NlEL66guI29999rNwGLUg2}@DoPjZ@+fRi{c(nGauOQd z@U;5?);udJm@qHi&dJZ{QPd*m&z@-X}IPqUfq;FY>65*@A2Zr?Gr)TiI8$E!Sdre zXOXo#q}s3NK4X-&SQG4Ep&k0S++MvlI=plwq~->rAxXW@=ov7SEm_2 z8WUt5O$@|lO9_UMLZR&W5l>7BKg_Gx(@kGVMUe*VtyX{Ue7+ z?*q5P$6ZCw345X{intgq#*5h<VZ=k z&)`2soDRt+xfs|Ph!iENfw?iNmQKPZFO&}=ymU%Q0KT($L>kKewBF^1Z5hsKiL2-`?G8uO!s<;^3nFv9p@|Vk_4ww^$%$CRvg3 zzG9v7@F2N;MANq}`gXtR3!23S5sbI4c_T)}BsuQ%Q77$_mxn5v9XlXqnccE3>aC|y zI}-Cnkuoto=94ttL5t(Y9?_2r^?Jl*ZFGtdr4z_k)%;y1x;j;kyIAXwc4O{GalFlxo#kLYU4&<@#4|hH=MLK|5zc!H?sMm^l@@LX<}N37RVOtz#LgA7 zTRZD`-=Dfok0gC6cM)1YS{L!X@mmfGl9-d72Osa-qps6RVDoT!W91(>K2@@jy`#eP zQEFl1utPVNVOW6S-9Wcp^{KiY$@QzHWZrQw2+TWiz*dM-1Z#0S8xhsn18T%G5 zn6GwZ34r+Ww_UBYn(kWSi^F4Vqnj(_kOtb*zcLx|?=soSar=*cVj5U4NNsU-8F2S< z|E+LnH)%A2r=u5bts=T`8CLz5;`djQ`YZVQd#dR9)as`kN0S&RQOFDBPVbAM2!jitPI;r4J z#U;8ck@ND$DfI=qs${8sQ`NXUANxl}z1ePd!q_R$ zFLh6=^LwF*sAGS2hz;cf+b^7UY5votHHf7 z;setZZ6u&@nNnGSXIxz)CS_P2v1s~Rbj|Vn1?saEmi_5;oiRPPtbGrE*f}*&V0|S_ z|1p2dK;<%E(L}e4H94PqtL!a#DQeAES0r2M6{MH+*zBpDRy-uPziz)Ui;-K*DqxMx z*>|_+sr*HVS$xA;B93coN!|W!{qcbBd7VZLW7B2b#VUAj<7$TYF%Oyb=6d-g>$+DZ z5y)4D@TVBABL?)d_R(Q)FjG%zQO^J))f)Dyu9m%4U`R!}wWrXho){flzBw z(t)p0O#r`9ZxpS^DNBjT_(^>cDQ%B|vKZjFUEro`?$c+|`qrYC zn+0p9;xxkN;zV4s>eu7l?@N8F75ILp_B$A-5Mr=$hNB58equiIM?O-quYDYRBuYbG z6I+AxBbwfb>FHe^y_##WBsI$W(m8|a3U}CSW$|@Z^+UA`HT16JjBm8XWJtJp8TW=+ z*uEV=e*PkH1{jJ*NJb>+&On?;7$C{YqyfHD;b8;dL&e18KC_b$rbu@v9&=qaFHgjT zt1;pR7ev|W&|&|3If#|MC&L^gnH8Efh{#tY+&%M)zj~U7SNz-O#3TvGj>=`nNfrn( zUDj!YEX=H(#eMpu4EIcANk=ml9;?Wb{yr4{|Gb-Ub(uAaPJd4R6(}19v5#3^{TfwI zv{C5gDL+0tKN&_n42Go5dGN8rGvIs2V?X&}j$Avnagy?!U4+iYIOVY@I|ee2-dkqx zn7%8TBex;PT~s)qkX!N2^tN0*3E7caJA*puoLGorT|ycuK4pjOa_a;$3*ULzAH0Pn zUIn4Sd$C)xvTKGAh%OR$9%edvdoD%f<(N*&OK8}nQO_Nq7kgS=K`?sO5>ropdP68d zeDwO6@W~#{bK)eNnjD;yuY|M65H$Q&DD~bri50)W=5DQ!tBV>66bL|p5SsSJVzp04 zKw({Wc$RBb?oLZG`0D`udBZ1k#Phfpo&N?*%dYjxopqSGRES1saC!58qX$r#O3)zGFC{00qbpBfS4yad$9p09Ojt$5ux2p} z=_fTsrHcm3znO{xYF9q;p32N1v=+%8d@J{?fbQ7L=7J)B432`3So5{*9gWcI5e@$zgsG<3&ww1hr z=`Y1U@&|RXCR8q_40eiFLF^u{(kHBRt2t%=`lYUr?}il?mTRRH_w=DBO9b5l@;ATf zqv*$*577Vuz4X3Dft!o+`3$=&8lpgn)wZmlFuSuwn3ZKPMk;dj>%Y3)#A@+YuWHuV zU06^b;TZ@XB>$_tTo2ZXrE`^Gy$(sI`FJ z^Op(XKU?^XsN6%yKijBlbDT;`0aA=Pp@!S>Oq&G}Sa3+d{i7%oN=50bVT$D}tS((+Y$tUD=WaHpAu zNc&fJ^2OCgBnX%dNn-3VnR14oeQjA3vied5bk-Re1|l%|X%yygpu~`SB%KxH5Bw=M z0ajw`y1ekrca|c0>WISJLQV0X_=oo%dU@nqD2ULS*eoUJJKu(6j$B72tc?S=F?1Dt zyr+^z3?WgeJ)W=Xxd4(jYb@^h{qT+&U8(+Ni2{#<${xn<`z`h)^w?|mOOIIoy2LeT z$j#JSkuLzjtCf;_6XR4^Pe1HiRar3;GqgB>NU)fSw>snu;7m55aAJ7O&6@J?!OIOz zGoE&mN2V>yz!U*ac{UycwPg!k=EvX)>}Oh!gm~HvyCmhzwXx?^1YZev@c~v?7q`4z zo#SS=SzGCUg15nSfr==0I9_y*$V&8{j6FXjXjnJUUOa~9w0T(PHNV~A@~}IQ)t}mp z%hPuc9T3yjM#E&}Wt+#jOJCPrpN>Iy<9?gxtvCVtFGEkm*aHoL+^sj-xoT~CM2hLY z+W+gI=*YoE`_oG=r|5PnuD9^-Of@5uvb`>pYXT=ivtO?)&a<{GBpmihCAl*|>6e!# zg<7W*%HZTjgZ)d-;oEdflB6GzG)GuKs=)icoi`gM!7#T^==zcn=0S8F6(kHG0#(lD zh$nV`2OmyP^@Ub`i0tbd_1v^ zwSze*xMnl_EobnLm$@57Q&)!eyJEf3P(pGK!;Tt+KCm-9#?RPxMu1#t$B|N7W zOtny;3W|F=kRTgoaW>dOP(-3lPr~x&)1(KvG!< zk-*DqRprC^*@$aJ`QFwyrEdj^kaG;l?y9U9Z5*nIqFv^4bw7Uet`>or0xK3>G43go z0*4zlH-u4q;6tc%@2H^Ow3z4FY?sWJc#rkmRQ-}P>8GMoA_VMTnc7jE#Yf=+Z38u} zHqbk3rQ~vNH?-xEzJqMb!U!u#rfa7bV%VBu6EzIcN70B63CJSY&!V0h$~2F z1`TlvjQ8$(FaG0&BSavX&+u%7^rAT+a9cTicP}aAfSjBx$`3B`4gX>Y7U3I=B}x?A<9@W9g~)inMWDJBzc)J`z6-aqY1Vaak-Gu;&Gdyjc2rk#km+DTvzmd znR7`O@%VMUa0P!~jLO(rOb$LJXnNu^6rK_YC3{nevRH?dn?jJQH##9Psorgpf0Jjw z?rtxpr62YBae%p^T0<}1Z2@#?WO@A~hVO(75w&!K@@h36fgg9*4P;LeYq2(LcZ4)~ z`T!5n*pn2{RthQO`_+%F(K$w#(nP#A^A^6_ix#xiz!4pB z>N7ij_;Bs6*Ica_Uxlu_bayRWRQ-l&ph;%Sv$J*t0?D)p{&a5h(Ul4YYI>{jgJ-eX zcP1}!r_K_*%He3oXEo}p>i2yj=?eQV;^gkn=O4wu%`L#<}E^$vi5OBd< zB#p@vb^FHZV8fUri98?zdCVrE_qzBsr&p?!0=Y=fTv@j{BbUn@zuN4ry%O3y4H|_m zeXMz#EW+iY%CNcK!z;zhziGT`WjtqV@8_@#e@9B#UDmphrA2D{GA6MrXx72M6Nc^V z0%BwED|>?3e%0%2*o-7s+&Y^choW5QbWw5%om;YYPJefOgpR&}BKGtUC)@WJQC~Z& z&8(j!h?dy1(2@Q5F(PncYm~QrDb^M9#W8P;l9J>b2C+BV6i0-V6fOUR?$XcqK8I<| zj7FY^JLl9s!RnaCK^)r7;leGXsq$*nbG!4kxl(hg5x#9d&2b`NyB064Tz_CKQQNcb zdeggJ-?=%(MkR==tLkLjW-GQRwGjy(@=`D@nw1tq$_w3eXJ0UwHjS8EZMK#X?OUN= zYoH;&75`qe)D$I9$p-ntxtdu&5&%0w+q08XEX;``rI%X-3%8P+kS<{HfTxf9JxMlR z18oNTZ6b8!f&<*SY?Ke{DtYV()>L6?o}EjFq_CcPV2{p4ASae7(KU9|z)yAZW*~Cl z^*LHZAjKn}9EPu{(0XtjTkSsO>biuErnvST+eVx0)SqD+m(W7F={iKX=eFT88wAFD zxt0vOT}w}(C*eBy9Cv23y7<^-R@c&ld=VKgtGqig(ig7m8PKiM`0!umc0~$B%-0U$ z>03cogI79*u1*T5Wu0$Bo&V!|Fq4Nz&QE3-7+S)S89g?*co^g z;IleDf6l!3`(tz3W12+Y^Ki;>d-VF0ZFxQEnNK%|rZX+k5GvkR*Cp+5?7s;O2tl^g z>o?XY?EPDxY|D7LeB_Fog;*BLy$EN^I#OMBdw zx6Mx6!>Y`uwHU?GBXASAfjA ze!Nd*bk=k+Te@O;JNXMH!fSa^Xom23tEUu69)JJrw5)Q1KH-o|etN=HM7*w8JYsM*0?60T*q9erbY_UH2 zxfQl?oTx>>(G}S9R5uWe64x=Lob@9B*g!x039~W~fQ=CnEBEHOI+~i%Hoc5`Y4Dy~ zQEh#~rtEX)om2g!7vRE4fAL$}fny?C_7Uwj@2kw8kE`*&yJ?Ld2OFXwWJ*s$*M{xg zk*bx*iVFgzIL3%U_hR5S22Ug$mi;h?4b%t`fNqu&Cy1e)JesCm`+3&CV6uUVpSa$gb^m-shAF0H&q6THf4T!dcv0gv0AZ#di+mC)D2 z`??$q$m$)jtec9W^QRM5Jqz{Uc^^s}nzh{zy^Jy!8%|E%@Y>;5`Fy*-1Y)|^mtTef zF^)nab<9?8EJXNH?KA0kh3W^;5xX(J#ElNM9yvJzXh3a3 z)JjXKWWY&3uc#5bu&za?O-VsZ6+ILk%QP~?eYX3JG@F86MZbKn2i?1E7(O%Iz za6aT4QsHgUXI&OTrY(@;7=2t{>ETJ1EGe}9c1Ij>D--*|Uk~5iX3jn=m)NY#HBlqN zA<9xCE|>US<K;e z(?tN8C#A(}#ovA_zb_*<^&I%U=6WZ!@i9B=s1GlwU!o(xGqbRBtrs6jlL&vkH^&}| zM9YUkD9rjK@5lr?Bkp!kP2LUB$!u%>z+EhMx_RPjK77jWe09Q~@FRoA13hz1sO&IL z+{+TJNO4zLfX=1^ck8-KI>8vMDObwwUZtOoC>A*2D7Yr4;~QFR2}QEG>F{}hS~pei zs*eD&GH8GIRkM;37QTj!sr>`HfR_b>a(;V$A#yQ4=ADp{(D9DSVpMw6*D`{RRUV)^|j`#$RE4E(1lAvrxhR`66TfJZjm99 zCr{iIHHbhUi93GL(%u#(&^9yB9en!)gk<}#V3Bvxj}41(;6hVjnUtt-w1LBoHrFJs zz#6aoVt|X}@JlOI`dt1mqe?-b%uGE_pGc4T(Im5|#9OU$NCY-plD1t6LSzmRC6<3^ z=Mg)7$_sFvCGMFg$*eh0(mVT+B1=rO;e+IWa?-SzwJ@1;5IwzlU8K{58QK&lu6 z7LfS>RWD24RUibAGR@f#O`c~;&aEG6_j-q}-G3zdVdkM|^GEw1G$`-7y&4R}D%BV) z@W{QE-wG`MOg@J`D+o2!N6w)lL~{Pb#+X76qB) z*`A~~A#2m>Wfag~VDKDok6Ll3{+hgS=-89!9Xot(^7A!aL20e!nqLXm)frq1&sl%k z0(OUo3}FLVtTAZyj6Ykto?b2|(~l?KRlao0@{dG(GD@VD31B(Jm(xv2(zcGqJE@(-1m#*egrQ5C5bE zaPn>bm^NmyTFTJ?)wD5G`8d{ovl^-C`&Lhu<4zT%dwzZ|(@(gl$JV#p={l5eGHHPB z0Gh);2)Z)rr#>gF@zlK|cZK)&ypPK`MeIzwCL8R{x%_gebl?||;?0Vi&{;imHo$Wt`j0BCExd2J)Fxe@o=j_-aI!P-Dfh55NRSGHDR{3Z;2qm>6#J zK_ZrSm!LY?-YNDu5NvH@YhG~=UFWZ|WPoC0=IEKT_X*be*~U)QN>Yxi`9gp$8t$sP_M_i#h5fp9Fj}DR4MIbLV z_5285+^=bpa=2!8QCjoF(A0rc>-9{MyVN`^diG)p1}Nc>51n)WV~gdEX$u)zPs@Wz zEEzVtPnD~T{$sW1nKgth(^#qah^p;6u@}I*UZ^A!i#F^_7PZ8xKHA~6+L7wxzDkoe zd8qO7_*NFHfGggA_|@5)wF4ft1X2~;AFpT7@pgavNAPV-ZU#p41qS6fFt}M!O|88* zT_$~)At3Nge7ptB`Cw;fzh!54t=eNPA}gyJ0~@gk4kIlKc3wmAFeLA$my^#cQ~jr) zX+}c;c)cRi-svPmBeD48q7Kn9`_>hi8dg4PAG!};Lp1IL)KqpqbW!lxoe=t(Rs#gBV={Bu!)7{wKIHoX1fMdbnPo7^cV;ufuE>=yjm+) z7H5H22n5*uz!-CW>?P0b3>HbZXkeIoHG(=UL8P1k)CZD30PRL_>I37=)+5iklm*k< zkF>a=K~^c*aBk&We~eXoHco->uM-|G{FC|q)0FY#o`v0yGFv~7E<&A$dVq4UNp z1}QLjIsWeGwRblkR*hAaj_RYXDEibRFk~wSh59#466*fsb9y?OI(T%Ji)U!ikJQln zCRphxGi@kMw0Zd0bM9x9zh2e6=c!--ev|>)!^`}Fj= zBRbN2@YnvD`U=TU55a~}7z_{t2KRJrZ3@;LN{Wh&V8`qNW^e*42C#~&4yJszV5H+! zBHQ?MHJC7Z}nQ+imkIqlIQH^)>&E{ml*%<7sGLd{sFHN!GfWsi@oadez~*^7BV5?Lxt5jaehki$5s zA3ykG{GJGEEpvrV&O4N;CUSq4vqsIV{)7}bA=O*e@7u)?HkZB8-xK=>zbKOgCrw7~ zgRPU4Fz2D~EwM@d56Zr0E>1lGc5OUcMtyy0qz&vM~RS%yL?=-a>5^yYj z%6N$q-1qAiH%3dbU@%>ve5N#5qW6J&HVGx&3sGno+|Xr#vM95fPzE?XDB=c$S<(qX&1ZnUCqml*sHuk5c+Mk0ORm%!_( zZqajnLS%IXMitGqQJA)Zlj7*W6=DvxIA*P?HKgodL_Ow)?jPC!P~IS0*%i=5P7D;* zR2lj9NOmCTK4?n@P(gJfJ7DlP)unpTB&iuV{38&TNk~>COiCNFjWxv&XM&fe8>CoO z6Q6=p6Hl3xpddLo?rR70KcI@1-(8=idHeRQu-g(5j9QBYdq*>{U_1n;d)Fom3fi41 z2w8jUU~^6(>b{I#GdVT$SuJNLVVLbhEK+PBd~cacAuk^l=y>$^jEGyLve14~EQ3zm zGe&hgQyBs$uY;v$+Zx{Uat%RQDH>{r*aC=r2dGa)_eBD40F z%LV=jx?dHStxy^PHOc#S$daG?$elEwX4w)Ix_(>aldHDIO%(u|kaF63nkCuI zgU5Yxam-;EBiDwVgfR4)W@edQU`lo-T4AG=NASfgfEjBW%=00>gwA`vlPD`EC-dDq z1o#C)C?(kh!n1P(ngNFFdRLSun9}gXkLw_UBZPx&+-sjUR<7 zx*0L+2lX$8OwNPeGH7xoP@ag7H&Y1l?&}Ju5K&n^J_M~Kk+6{q%OzB34KwS{J-3Zf zNanz#R?qx-FKmoO{VF5tXn~thnA#;!c`)0d>s?k!cVvpV{x_PL{d9P=`h_|O{GMh_ z{uR}==G=BLrsk=F5#lcr62ic9)(p(5(=C&PoZA!lEL{#JJu|_DoKnciXlbN1oMgo) z-?AeHW{mJWPf3ZE2_-NGYfnl(3)#$r>9Pl!btm@L?IoiQ9keon%#&x`dy)~!tdt<&LvCHWk8=%`K(A*7Wv7##*Kl`J%ulbp zkSH?8BviPNtUpJs<<%EWYF}X>C`Tl;)0y(`#G+}_W1Ti_QcbCdUWE!8J&O@2rU?|D zT>}YOX=p1E&wUegi@K`-7zEA)EZtoD^+Niu2|{*&c`ZB90)}TH0^y0x06r>uS! ztwsp{0>8b%pcfHXRaFIr@oLe7VN!u-x&_G&0ktMPtZMc2k%EP{ehj29-4Lb;qO3=c zSv1KN{orS6VF75 zR3o_`e{$S{BNYvNlT-n+>LGu3(zGlIwE(8DGhhazfRAq@GUvendNwx)NalLAUVLQ+ z+{ghw#XIL<@z%WNF(1X>eGA4W*am<<9zv_{TXIvl%uIA?2Ha=y?XeI(2kUb3 z^74A))G*=<6Ce~$p-v>szbWrb;J5*qBm8AiM;zM9bN(GBTa{z)@h=EQR6ZHe<^eW* zT>3-o`Rfgc3$LFadsXy;7#Z8mq^*6iMxw~!wou;mh6AyHmG0?jq#n7t0UfBaVz=%LY8=aYUK6KwAixCf#%Ouyo8`be^ zyrJdzh?5U){)mc>-kvp5Z7te`?68^WOD3D>E{Q|M1qB^)$=F7RD%seMH(geb?58U4 zq>rN>V~vnS!o==eIq$Trf7B%ReylHzmDs05=1MRpCIx@rhL#v(w>~*97^#}%TL|-- z=3sbd3wPy25KQQqIX~GT6mz;sy|tWQG>61ZYbz6<`C&Nu7Tm|d&#CMY34)H)sW6Ds zIwpeYHM3H&53QHeFFfW6L3CbQ;^#rR#KFo0mN8D?f&Q6^BdfXsFy+P|ib0Cr zG~i4CCg<@$?!3aviV2fJn~=GwtgNhfA4aP+!IbonjfM+)i1*0n%|(vuy|?@fY}JWI zM@I{9IBBGgFG@oY0JZjOC#( zTtazsYM3E?<2efNGG1rYMj*2?Entyi%5RQ*;(wJ?F=cq&y;K4Gotr?#i1-Q?9PARbcd(G?B^_37U;bO|okxA9jVI8X>xb**x}kPd=-i+5D*1 zl-Se!dYOT7-7_5&k+64M0$x&7H@GNv8#|_b!y`}HqC0*Di8fYIkyaErQ5HE!>`5i-zdafBXCp@|(l9QQn2J}=?Z3J` zW(bQZUE3g{yQ=zc)O&7tkRr*}uW3Ebe?wU2^8vbtj9m|NqZu!S@##IcT?>SdRM8Tk z;%%8Z>>->CIcN{2HT}vTlZ~WDq2Mes2vYlb^#xNb*OyJLqyKh@;#jC8DL^`4u=RK1 zB^??fx?i}5_GJ2WNBzCqPXu3*2?mqF|8?r9GS~%AQg!k%{Bt&*9&?NM^(Nx8x#52R z_tcj^j2Z0&3#Egj{FVM2>K=f3zgsNfzfq8R+kCl=_+}FHOCt(Q)K_x(4e0 za*Aiwk!0Q;q?s~0n4T(P{%3roL>+Zv<3+eqvV49K!qxGK;N&6{4JSi{pU@ z2{#_!Ng}rxG?pxeja!?Mv-!ZWb(layz9W!FVZK;Hu6V&!?cMFqrz1NG?x%ayPId>7 zW*z@7%?4M0P*-$kR%&kjGHm(fiF@bU$Lg6Fw}aRjJ!}aI{Q9@Lf*Gv~%p^BYN>L>m8m=9$T3zX*gxa?5TgiNRQaw z){!q)qumuy0M-1U`8QSv(TM;`;d!`zo68@&$tqEjN*VjCG9VNNTFhrBIoXxf@ytlo zI+*Rm6P8g~nh7La^_OQ;C*3+<^+aBsp4W+|Vxl-{#g);$iq|Yu``VA$-_~8WJWh@9 zoc6A1Lal^BOQNMV=)ux3SPg-QEDp7i@$Fgj1cfJ*ovJhM0a6T+*FF=wcqFZ=Cwwqbx6mXJWST^ z^o5AqTTzWcTr6TXrIcjrm{O&|of`b9CuVp|&-|qTGttz(VUJh>#=MoHh*+*V8mn>BxA(VrFOT2CQ{ zy%U;F!iQMF7`8`og8q{+sAOQ~sD!J<&0k3Tpj#!X%osITjW9)xFn9_9wgxFWSWO&= z?jdSv^hh+d`bCSwtgBtT9wpi4yN!Kc_rl@z$D;adWnoJ^9nytWiHiI^ z`v?SL@~$BNc~}PCG@tBp8`SrW!mNKd)xe3(KaQ_aTNeAxc)q6o3DsxCfN5v{$T!{c zaB?(MCmn-w^pufZHvvk#-rsJUCue)~m3mygwl}#41(-CRlRm%qxRdM;FgoA9l`>hK z6^O17F7|I!71a034Q4!#)t0E^`mqW}dv8nFO&_;;+C2o`{5h1_Fr*0`^+~a%d?qm< z9PSA*CXEPrHm1B%?*HunEM|a+M%e=Y>b(Y_pe`8Gxiqe%uWH9LRLUGk8s0zV>r;F*fu&zcFF$!LyFX{c3t}AN{`f(a6rEDmctlb^4!PHgYR^&R z327%2jo)JqkQ%>LVSQio|56gklD-zMq!K`m_6h4ZFu&m6sOqjP`YKZ^iFs8a#$h(~ z&#C8+Kj*=5+YHR`jIiIDIkU6+JuPEGbkv&tZ$9Ey`ynG%?Bwy?Fs7B$urPzjX}ZRy zXck0Zl%!Jc+e5pYKMvm_~Q#s7~2N}cw5naIe_|1i}2BKQ%p+exCETJKuR zIo9IbH_05c?Vn!?CuEp1_PHsa->a^~dGC`?gr|7NGjyp6DW7W!d>Y})c5OQa3HDn5 z?hL!0jzaWhIpU2Te~Opc6M@LMo%Kb=oF>lL*-$^u-1K#5+$iwO8+l#_g_R;FWW?&6 z{kC$gRtEn?bwA*ztVH=U4Mh?R7nHTk{9sEnDhSuSpWG`#!w?Fm))Z{=5&||W}d|HboJfkI(Hj)PK3i6{D?{QrAG13u_j^7vW^R~J70 z!O7X?k#FzHp5w@WULHfetPVW3Cq&|?O=7>y2TL$RK9Hq}^55VGN3~254}lz=u3ly)JDfWG(DkeQum5}GVb_|j z1S{vC&pfFACb)NEDVTI60zWj>q$pIa!_@!(y@OvIyCz<-dju4Uq$K_O_wU_fU^5>c zdSQ6W{W*!q%5p2kTXn*66$*<OmP?*s{2jURFj5?GHLJ53RCGk>F?|D@&|Ggpn|}``;o9tO{hJ zuK%x`fqF+-d3MX|U7zjq#6+CPwjUN4Tph|dcQ!@O5QtzoEcKS#ly=qe2`G_#BcjYo zLH)mF&CRbsrhtFc^W9~b>V-B=lgJ8XyMiF|DtIw<$m~pipa{& zW)c+CnVr%iks}@xj+gJItX275>L|VE3bqUD2-M+6M z(zA$$(BmS1<|8A!Cii`(eR1a*&aYREm&KTv>;&SNn^yL2$6Hb2Vag3p{Q2Zo#*k*s zru1(sEI@O)XSMLILGQlZXoRyGOc~^CT{i&5&=M$&5RQ{aVn8mAsN2m@Z={etI$!?n zJ6~$?Zm_g7`VZn#OYHpHB-j6XczG|mzFayCLLdrF(4>EEg8RdVv<_VrIH@@!c&d!8rkgjt*2isM zUX+|Hck{|#2)CFXLu?0H{@|FrM1&J&B*9ttTGpSJ$Jy@RI19)|UwV|BKDe>dD?@0$ zHP@D2wC61WXFFd&%)TNMGSiI9^YpuQ{=o!H>n4FY+!Qe7a%B0;!4VpNjYvul=GtQD z8oO(^8aD`(Y2(F}Anyk{+GW9>E}5pGhgF=lE;`~)FZe?>=QaMWvN?B5`r|; zg}H8?;q1+H`G+6!9RHG4nA%_{+$P8p1iU6JKFz=g!d9t^qq|`ihZ_q*4kNuwpI>B^ zlw5`>J2c(!xx#8C`?f4lbY21DkhHikJ7da{%jMF1zyE4QFg83YV(Kc+I`(lmNlt-| z*cA~AM?8rs$pj7mt5UEw?(gQVz?ofN-=$xs-@ zQ451&%@=E7;7IZK8qQuqm;owoafZ?8`884pZUw6+B(A^7m%fW0p7|pJb#kOtBB(I) z;Yhri?JjlJfs(96$8nAXGQda6qm_>0z3I~4aLw$yloF#XUjFKxX;57+Hlp~w!Mi?J zR+$KKpkg)F4oedX{&wwSG9v=zId2mgdftvZKRdC6=7jl4_T99Y9!Z$9V(KSg-JKi` zGXSZfXM|6;0fXoB_rqTe4)G~|HG>-`R+Ub=j>DrOC;?gKmw|4&Yyl$hYCm;5O8p@M zfpTG7LmU2{ucQ8Be%9czitGy>NE=Yata*g42~pxHDk|Q~)$RMTfo*oM0j&=>!O*+3 z``--B+`txywvr!Ep?XL8|JGmpe;h7y*FY4G)N9Z2>3YQhZ=|rt%#y7qfzGaZ`tWGs zjpfny0+MUD?n$N733Nva4~iPjo1sZBa{@D!V4@;}8T<1wvS z)aQOI_TUFIAeY&Oi!+}3^J9Bom>u5P)6;=uXRq;|94)480gayyO~@26&vqDqLvw~l zKr0+rIqgpaN{^7Ph1+Us1Rra+?<514y;tG9zBhP>n@_P2vWGf55uU|byDH+o!(=&w zwlu{zY=zP*8X0UN9U7#WU9WGn8E?K6-}FgKu4~2ggu^^YagteHTJE z^mT{TM8fjir(z_0W?Y7(_|>AzUfp+7RKoW2%gw!nA=eh2YFVDpSE!lOJFKn7=CM9H zz9fR+ExzkCGK?RcDHxMS-Jun2DVkxe0x*D&k2wG07+2DCkB~$(g`7H?91ACs>7j~% z!S>0|EiH;%s_5)_dQF{&n6i$0gbxTRNes6=XiEe7Xnh2Pv+Z8oU~Lczlv&q6+;XYG z6>WLJpnla|C3-gDL!On2zcT{Mc<_Svn$w%xTNGa`?JgnT_`{G%dzsZqH|8{-0F$Fi z-=W_JD$TXD*jVyBOFI=G2F>q1fgIVS1LvNLiu9V%w#Sl#GYKE)B(j@-SkSs`VD)bL zu>NRwgKPCUSVOyau|pa5jZe;S)m3GXn!3O5qMO(4-bL~J3*qccxt4?9wtY9U$F|;Z z-Vdb{OXXV|ZD4@;3?iqJ+Vcr9_a3T*;zTl@`0-NXxqO~fU_}>EA(f-V59u9lG9xlc z+ekR$Pl`D;V(Pka=Z-SWYG=d<4rFXJ<88Dnznw3!y^|na6e^#I?ML?<4URc%D&lub zW)0e<7iky#+&O`UHTBwfpCu{+qV%x(OVWWVh#Y*6eyMPo){8q_RPU{9oop|p@4!Ol zJsr^e1pS|44`f%=B;d>)NEvMUA_|~FFV4>n$K@!@q3vYuc(IdqF%xic!cc#*E`}JC zGl5ao8Yz->FqW)pqyA)bMuzZN^niDSoDZ{XGeqQw7*U_0upXM&`8mCoANs_p75OZLu!3)TcY;P>_Llas(2@M z@x)F16V|(?4SPq0j?|77IbNctrlJ!a-WJ6y7Da^}T04GriiR=2muMYUKQC^1uar4+ zfP2X866W+~6KoOomaCs<$?AF|P|>L_F* za>4HD1hCSlZB%vKU(M=hY;eawywcUVCYe2!v2dbA(=o8=ch^m&;^QF3j9L*%?p{?G zOm6g88-s;>C+16>_L&sxKum)ogC_W>0cfrG$2Vr9!q|wtkiI40q ze0{lI+{pfA8*5qOE|6j4L>@JC_2Q*jR&3s{v2s|48LldE@^OLu+?-e%{6eI=4IEKBq-CSb3!({ z__XcE)2^--FS!ANd16MCL2DxI<>q=@wdNN)Cui`aQ;{EW)ufYGok?qZ+p~z`-STyA zJK3dg)aXIC?!^aUI_cLmX62pb7tJs+D&eONzc&;@G(sd?p1{4eR0~sm&4xc3FO3&5 zIMwax+`n&~QBq5b6jt;|UEm>}6HG5I=YMR5jQqSmkHiUDL`V%BjJeV)xsM%zgDZ28 zo15FHZvJ4Fl);juCHOy9Zg8BBB}_`mqN4tOlv_Whtq(om_aiZY`?B_pDu zWo0F@M?{4}MvJ1zV^w6Ay=SsY5+Y3zSi_y5-C_x|2rr02Px`@XL8 zJdWc$&ht{&brjsXZCmkkRRH@PZ5QZXf6k#Rc4yjeVIkl!^7)pOTGBI>qY`sb1`ax% zyD-|Db)hEp3yOrDsio`;{T!68?ry#J&u&0{%IWPrEs4U*rDyUV0zxn^lU;fyOM|kNUT*gkby2<{$*OdWvZj4U%r?r%8{j%3Au`slGY@(K3H+T0|yc!>|YM$F20v8sr8 z#ZLF1J+$BY&`A5^eV=BErL>-71**>2?s~Xk=fjD?6CC6>vg+>i1)brpEmg(0h|B-| z>(Bsp6@zUj*#woJ*Risy{1{7O`o~{SJZE0JTqXbQi$xmy+toiAcsw6mUixfH{qrsL zE`t-YEq7jSe7(g*(ko+&T<6#g1F<1ni<^vT?enFVz>?K>pC&%*p55W@uNU6gJzm$4 z-4!NK+-bLA^XAPqpCSjH6520wI2}=$I6r)cvWMS;F~nq7p3TY1nX5L#`KPM3u})v) zu);j=d!E>|nM=>@w%98!%(%)1Ot+;8#1EKs?l{n-k2oz)HPn>bupF{dv! zI&`rhgQ@6UVZEnwZm!+27}fLm>6c92J^AO`ueE-9zI(91vqGp)+~)qbs$KI}>0DTc zb5Grf>oPjnfiHt1Ioaha);QmFAu6sUgEM@1#WXo^Z_>&9^gz97`7Q;rA7u?*v$yQV*hA5wX{jiU>etJTWX>?kNvS%OQ+_}Vf#r0~G1xz}j=vLi>pWI+G zxOY(s&FqEaM|KLGVv!C1TygFC^)+hW=+Y{Uckeq0nsoR#^KTCM9H4Spv}Q3WPm!|b)N!Sh9L?0fFt z8Xd%`rBC3sX?*vUV3r+ktoMHGr=gp766Q_oN7~_+Q`T(Qce5zXs6tUkhn>$M{%hH$ z(06f&1+R}vv1x7o0=q}i`x4JB7sstlGh3=}>ZS?o4KPa3NTuEDwrHVXWz(Df?1%SO z`)Z|IyB|4pE<|E~V_gPUmFtS`trHs~tdH+1A8po4=@Vw(J6m4LE$+9R&~{es+U-+a zSb0mY%>{^< z%3%}QO8gCX??wv>>g2Ids-usF?rVh-O0Km1;y1<*l|k-Enu|ec_7pn*_=2-RJ7Gea z>#F!fOvauBoNMeh8R#MUdmzfNnz|ysA8E);n^8H&6iBo%>&W)jTSkGG!_b3^5 z$U}$TmYsOt+1WpEZYL|nwcU9#@rh)^=A=`=fJY7k1G)nPZeGVf&rG6o>F``7pTVm& z58bN-WFnVC@aTveerhnbPPFnesM3KK<`VDc zBfu`qn|vz*ml!Wa6q`nspR~u zC#QY@1>+9msNzMZb9*1A7_QwPM#7hZO4Gf4NT5n8yA&2kB6*wF2@4;)!}q~5tw zXz-I49|N!A?>FSurWrfP{8K*k*u2uS`%Y>7_~hr4!q-DxrdK1!l#YIC1(98W!h=)6 z)X>3;Q(sH<=9+BuT?nmV!5mtRRQcM0iLmrL}YJVpe$yUk?%k_A^=2H!kdo}53r z#&yFsLyR69)U%YFnL76}cvpI}0nRLErMwF`etO|?g)?Bx<70(0C!&8KuF;1%+$|m$ zun4wmF$Rh*-%%oeBcwN$@hco}^`o*Vt&i>7jjasMkzV6dVR0I8b z{MHL+7eD!AM@K=v-eOY4dZ+TkzT{N1*BiwrK1Sxi$KnIiAyh(S4wZoC!MlVhKNp+Y@ooUkO}QKS0fEH7#0dHkv^3sLfEs&RYfC90~naSuHPm0rK~ zA|k$}yrQDG;j;g%D2>R82r2M%eMHQr1+>w;53ZAawea$iiTO1LW}5F@@B7`%IIYlT z3pePiuY0ey54v+M5DuzDk&JPAQs?;0{{Ad87e?>+dfCqHE)seZoGm@|-8 zkz(?S{US(h)1z}HrY|L5Chzt9r=FnllSxVqnC9(%%ewW4?j1 zewJk`MRnE8^@td2@0Ny!#N$e+U`}X7zsAa5$Is887N1#jZpZ023$O?Q21P7fIF}T) z5VFFF1EpbkFsh7B9&&{tvWHUEnAKwJJ=>=Z29YxU-GaB^f>G+#db zHg_G)VXw1$n`;Y&AFInWa_mi^ixfRQJ#o$Y`7y)jF@=_u7iDGA#>U3?N6|)0`m=HW&UL)Kid2nvXDO0IxR-Gbm-*Y7>xrcZ$geNb?&M&E_^OWEej zQ@xtqMy|;+f4aC?DodcrTaaP2xMzs}En7qd^wOeGy1ot`^SPVTX=NTAIJYC3+IXOGqU{^0d%@xol!Wj=Dq&h>Hm#_b{cTch>D z%S92Zl$lA@`6z|C1{XW*Ai=tL{*<$ntkPBN*fPWSB%S{AtF8~~`qHD(uh&UR>hzvo zvXoaQz;IjwllQ7(x(5V*77<#$ZaQxC$2J2}D4{M2b9g9rq)XcJuY+2yU+1}Of8W{S zUeNs6xqt@0%msuK*PvILQsjC@?SH(eVGh3&g`$$ce%Y-=s4ff7=}HV4dHDao)~~<> zWIUvkm_?9ZYKPkKPqEpmA-p1%?av+UICN$sIdrD3;pY;->#o0pmUs^lp1-TnH`dV1 zi~k?z(nv4iH2rSn62*!MwMEdy)slp+F; z%rdfJWSQ=FEV|e(Jf1vw>6lr`9pMqNVb<#TRs|y)x#Z2iNp3!o z%RKY_m5U<6QC54e&HKD>RmGk{1qboqBM(5iKv<1|m+NjH0EUf<35 zD7kgR-zJ9p@}2`vV*lg;QtJQWkbh_AcL#x%3|e^=cbt4;u|R)(kxZ6v8s|}#Ig_FG z#1W>EdvSGJ(8%IN^XO2;*s^av#kT5{B}_s+!RHne`SgultL24mfAto_?~h76S9Q8` zI2TP07V4Vp6E=5Lc+Q#0R`4B5V*QUOQi{($n@`wPq$%tA0;Npb6 zRWCimwut0qM03(#XS+vts@-O4=$-bVduQn+od@?`k63W)$ZNO!BsJ&@XF8wjjr7&e zwk;yTW!rNcV4EYY$Tct45`LI}uFC7GCO?hTC0jg`&0pUce&L`mzOrqa4I=X`ND;CA zj+*My(lj{Y{t7zSI!I#)9h11C)nm}H73X@YN+6*uS-aM=CUSr0)VEj3+YUDP9+kX- zY}5g>dr4n?{8uyzg`sem?=bQd-5Y4}U@>>>ytP9gsfps+pY(XMO(HSaFW-2`^ghk< zrDXi)KfVr}z}g{>Lo^H=-dqqDzNOb^yFHK2g-=F@Lc|{(4(w>nw&F<2c~4K^0yMy7 zpL06B#4NQpuOdIZIP+snZ&KHxmz&h)&wY4#srjPezI>1t1N4pT9H~<|^ZM`(X=+q; zDv@fw^}N~-I6|R>I*qOlKC+4O4xeXXX2r{whBPac`1SwVe>*QWiCb|{!p54sgtdak zRxMdC0vt*eU(Asu5aU%iZSeR{j-a}<00GU*9D+Yu z{AfPdEr#z6j&;H=UkX;@jh5;n>>NH+B&I*EsXt|}&(5)=5c z&Fasn6l2n7_S^Ykme|K+^ehm~M!K_#ixEq?Na1dtA(=y;`5Pl+#L5wb6g`RL`E!(Z z+I2cCh@kX;mk{p1a$a~KszdP2W4G0d=HVk*yva#z>zu!swFukfI_SJXZ|wtA(ZdbS zx7lxUW8(0F-nwd=@k`eu(F|^NI0Io7(t3VLo47~3~f7ulJ{Lq%wT?Evr$kiw07Mu@!;ij*&pLUMGH)}FE=;_ zSBt!StCC{yeKAWy{k21-$6wa75;gjBZvSQ?xshAgac$L8FCpCmRw~yzmPPMDhAs)H ztA7BCK!Mao3=%m85u6_WxKwgFGH_d+d1lV1tkx9$5_(|xH2|fj-ae^0vs5Ji>pyOX?OIE-RL}hhLDZ$e4g4}-`SEfUgPLNcZdbKrvv3`@<1kmP6x6z^ z6uD0i+qT*~Q!ia@ww10S>4azIsYcNYZ0^NNM>8=`S6)8$^=3iA#J15W_RE|ZW{ap> zXDjUg00wdTe>=&fqnR4+WC`6Is1B!Fi8Y6}=y=o@#_CvWEBX3A1@eq_Tu|Q79iB_wD!-R?%vx^xbFx7c@y8(iYPU*XP~sc(cEiLTwvwr(!>g}5n7i{!l6Pp|?Ja8^cLRJa@5`De=uOavED`>*l% zO;?n;Cd_cM!433Cj7US6oBJt#Xi2j76ZpA166rH8&JThZ&yNpQ%p|o4($IYN3lz2Z z6=Ur#QXo9;w*3v)T6(&=-a(j4ILC%xK)1!bRod1x=<;!|o(AulkDgOKjaFWjhX_lO zMdb5?DpK|s8*gMtVSY9yGi>lP1ixOh{o_s^?!PxS%>Ik>G4cEN#O26=Qj|iqdo)VD*iz9W zETEY-=Q3v)bfU%+=h>2S`}j!6vA4Ge;r&<_yWC-PV4+fii(3?Fe|{Arp^;^=y1cx+ zI^Eu?S?;$)ll(`)mTFPCP0|5I0nKtgZ{BEAiXI*g zT4Q05-AY3d4N5#bf$ccb`PU%&e@%?Pv)+eGp0p=Z!uET!Gp<}JGIme>aNazE8vfw` zXYm(V!cjP;=Q+IB3Y@I$SOUn-0}7%uW$58(oWI;BRkdU z`{JE(tSQemYNF8&Iy~`%^Z?C~Ua(u8IFhE|NIEuOX}SG1O(yX#6m;}q?k-!j>2+x_ z(Um80YvhE9ggAUH>&E(}gq0oKdeMF6J((CU`5RJ(Q4?}Yp>99_SQ8m!8^U~ZAXvOV zAst;n_rnf&|MxXX&q~3KzwK1BJu99JfAql0WAz-y>QOpQ=JwATitLb!o_3#oGB~z3 zvZlyFbZ@lhRBvde17oej#{aMxvynAiwjMC!{9&3h>^UC~3EblB7RLNwXZIV|dHNo` zJq`6tI>7tTC(UEW<|69mfHUrOuWH!;g8pa15Vjv)gV)69kRy!_+JNa$uifYn<284?h+33zRa`l1Cto~86un5{*HOf zejR*cf1U=hV)&rK`AaRko}c^~eB?x=muN-Z(!yYJ%E_F=#l!3I^EA}! z*CXzM*cW%ioadF1UIKxW`QwUTH?Um%)2A{58YvXC|6f6u0U{Ow^~jROy8Y>aNn!`!>3Vh0iulnL&pDk}nHJC4nc^oO{q{iRT zA*Ls_(F%B6v|Mo|N}vGU1~G4;9y;Vh5y{};}Lz_Z1ZN~A1|OEelVlS zcx&suyzuef=3b3{qIIoq2r>s=c*GvR$-MwZ;_y!m7)@SfMC4wS$$_ifcavqQ9cjhyKmlPdG)N@F z8>GT0c0Z|2@>5eP9X^)~yqplXwxCou3|@HTaifkSBLx1jH|LlZvx{Fx4W3g;4<}K(Ys^TY-Zu*lpF4VMgd8sW3oyp{!oL8Wyvs=+?dhO??HSqipBjENw_e3@3&& zGNr&Q`o0^|gAKel+>&b|?5@mug@64_QCe7(iYb|_{&SB!dvH8#2glvR2ez9o@zr;G z61Nuz)vU>%*Xy**r>UmF_(weP??U-!OSe3{76LpbKLH%-e}40b2b<^knD2kBwy6-x&<-4rTRX{h_Pc<`0n!ZKzI@O0!@lutjNAVdfa)Aj(Yl89 z=6je!?c%YZwuCDvX+D1Hd1bA_-nklxPJ@dn6i`yx)NYgK9ye~#P|(E2nr>-tzl&02 ze9Ga{oqdr{0O&cgk^$z3Ht+20Y}$?j^E&xuRA1j&3sV56pAkl{Zou>tVnzitxeYzv zE+2F{r|d`g2QyC=uI{giM+yDC?7WcM5%&E$_OI2eSA1SlbB4$i`00xIZ1t^9PAVSq$^8Xk?lnYua_#$Iow0Wa{U=CFYqCQBhpOww0rK_TPl9+VbWlt7~emR&({o ziW(mu-#F%eFP0HTMWLrEc|@xFSkQNroZ?4oP71$+b@$m`CW))3 zk{-=N-I(oJF=sML?)<=_DGD7FF1x39ScmWK=siKO(c_qM8?bP+rea9qN#up1`j zVlVY$$H)kj;7@V4GUX63$JfsLXI*%`GB+xKw=J*a7tKBYV8`}ICns%x9To%Pq&_Aj z)#Ua$e@sUJwg8fS$lfK|0j7-H=2{5Ncah-H3!gF@-`_ixv~YJeqKih3^|~s_Nejf7 zMOuj~A!QU_U%KI5y*qqQosdDEAgsATp3=woofqZhmtXZ1BYJ-APcGE24wK#q*sEpn zMppIKZ-T;Vr{zjHiL6GG1Hu}%)j_tlE#unG7X~D`Y%*Z^)(;v}im!iP%Hi@1COl6N zVm8lJLeH@~_Ld5+WWc9rBSW~0P+Px=K^-i_t_-D{0t%YhJXeGA1qtqng=i;Ae9uYj zvJ(&$&3e+k^v1_$e^Ft0;+ZXk=2N0oKj$)#TZ3Gtm#R^XBI`|-EwskzkE%r0P_8El_iDYmFw7prmNgY7Q1Qi)G$lyW(Io0lQE%UNMZF+HvnT4o17c~j9c{nR zd5baRbyF&W*r7YZ9e2ho2iDYW^}8-jdd_F-Ip`h|Df^E?fy&JPnBA6mFmgpr&j2f2 z=FgrmK0pxL8(_E~!u9GvpV9o~Y5Gq+lFdU8;z*^xz&unp%&OiZb`T}6`%8$#Y1OdK z#8m5#vfIQQ={@@U*Dl-L!dbBu4Vkdj9Je`!W08bghAo>8Mp*yNI)2S}vdl{8b=+6_ zK=Q@`wwP_PP$Lgy(4s;nqU9b zM4eYK`tysT?_~vrVrsK~PRjVTmX-f4LM@l2*+{tySC1YbhV(r3!TUR?@Nx45c3}{ zj$>>ba+xzOQ~8g3Z*8aL8m^97#k-!3mrr4-PsC-{r^oUpMm!=SG<+Oapkpt8LCgY= zrc--ljxC+Tyq8fVV~Gssx~R`pgWgkVfg%gJ)|H(WP44ORaSz&NVl0_{vFgy9i)WF= z|MRtnH+9ZB3dLXJbk;Ew@B4vVbNA1EQC#u9>$)p-gLlWFRXw4h-csM^=d2Fd;@Yo& z@hRKwQrA=Z7EkZKkSY>cExPy7E;V_od$iof^s=(0;c@Hc$Iqirp-^I;olQ>=%j@>z z;^ll3I1|4Afh>FRUGg*bKmmD=7`7wRG^gE67QM@53ALWzwdLJCmLThmLrm6(-Sl{N zOQ=%~V~qpknG3`8eXbm%L-?bp>TgV!XO~<#c`9t{dR!U4TPcKppe}AeX)GDPx|`FR zrtw+R)ong5uKjV>w|qz8p!eOAE2D#z#_5+YU#^RgV>n@3f2`Yintue=3ZBiIk3bMq z-LuD4#Q;s<^$0hlF9E)iir7>?|A@2IY{{7dPH=vRIZb3d_hRcGP~2Q$$l}&9z^Q1b zm0`M0-ow+AKkcZwc^pgvJTWr*v=nBq$t{$ed7n!u@7CmS(7B#^=e&rLbZK%6--e*d zhZ2sX2dMW%E&0-$2fD8FHGJtP&eSjQkjb|F{6-TJz0Vp^3ymjAE86{dRkmBTiL;4c zP{c6G^X3A$(mJG$1caHn^XKmt^umNVQ&=9>h?x7)($Q6PvnHKQOp1eDr6tYCvno_d z*D#d&#?eDXMMdkS>)ii5Zwf_m-aIDugfCyLn=M4|?>r-unwkob?CZw3_Ti`2?A^Qb z*4ut+Ifhwu-D;+)s|B?(4lt|~sCK2HyP#{W$$XOLmzVOp1Z}V%Ma(RhLU=oDKUPb- zOnr5?`}`&rj=bGlawBfBOK3qjva@5^^FWSfB^KHpk;aD^&lsptMQfbXE~qBhm4PXe*NZGiG30iU91t#kxzG zhKA;W*uDZ7r7YX>G~3$R`f4KGpB5L7ps%P8dgwKs!3P+9W(tNNcIN%nh{(wL2L}V! zzY4m0Hx{MEV?bj=-@d&TY1EE<@#2NdX)x-^Hucrx_i@l%eKqlwaNyB)o+^ zlX>OJjl6^H>443Fw|4K^r3o(P34c`N_3I1Z{JTXMI!UQ#s^(zWi?1U`Jtx@AR+;E$Yr6s+p(-i(1^9G>#&i`>=V#9vM*_L z=AsO>PXDBYCT)H&>r%zDU0Z^AH*HW~h>>&hdLlAqQbmRkTWReYZIa)aen zvePt4Whtx6#X>j84<&x!_I;qwkfN-j^68p5Dz@p_ukMK)1lbhI;52JU(3}8bVx>=M zbDp$x0&VN74!?rR=3=1M=Ij$rBlQy)y0&7?8m+!*0G%3uYa7h0ZDvSOa99*y9|r~f zBW`wp9t)L=gM%agTh(r@l#`d>4pNSwO#p4{MU}7-gQ-}nCIN_6@^oqDT}Wkz`hH*1 zsin<0Rl^2=v=8lrkO52MbWONotLJlEL$#9m-QbRNaqfShd*{uSCCSQ5~q98t3TO&Qg9}UU`%UO=7DoGqr3{(me%>*JAr^ zF4I#HZN`DJvyUbChE&AmwVa4-E2idKQTSM1u@u>Ek?dIf!k|cGi)hE8E)Kvk zFQg!{T#zdbh&pbSe+(_ze{1vlFbS7&GGb`^JM|^L@ElDaW+9O{J2MftlP7u(ptyJ~Ta4~yz*T*Lu zc>h>PNQhRZxxZ5YPqFauc&GO%Y_iey58uZrMY>@MU)=56OXQXH^&>EcF%nbSK7vqJ zGwyi9iQN>d63w@Br-sz@cqD`y!*(e|7^I>)`2FKHg=Q_1xUHU#gJ`>okD_B_vswH6qb%N?7X7llOUt_YBl`gh5ml?41 z1F`c;_6F(A!|+n}VNY*9)3%dAr0JJ*LoqsuuE=}BdY2*oMk4yZ5MG3lwN(WfE&iCw z9FV_6f!f!Q7!Us-FWSm6fKB~PVMfoJT-*5C+S={rbv*9e6NfhXhB%FCx<8=zJO%gU z=w&Y6yFD18JXmj z(O{#RZhYACZSFD5M9k!9wGizGr}53sK7XV0Wwvd<+JlD=%~8Z$T%u0hZdA^UC3?hh zbf5+!rxk1yOK#;y4VIO_mHwT!L|s|ulD{5{R*rRezM!Ds;fg>Zb!7MY2suyX`@5yA zP%G}ey#o}e$nkyO9U6vT?n>CALs-XHA(>Hm&7q$QCQl1$r0gLZS_fg4U((se#^zF% zd3~Hb6~*FD6-EA8SXK5gOvo#z8&}c~j|>m1+Sp{^^V&C`J28Ov0KV#mJo|I<%F4=A zu3kvc)Tmo)cbH?3%CYyqyt|DWi!o^wAeC@w22D+CDz#6ZCj&-xR)kC_KuDlB`)PdbB*(I?A{EKCSwG9)pQhkv=o6J7g;FY}t#LjIKBp2Zvmz z(fHb6d}8{?&PR_P#oV5lXZD=}1a~OjXigA)5L)KP_1%5qg@xYPgQ}89TV=+Xyb|0( zGqh85fTX|6T7fHjSAEDhw>B%UIr@g=QYOu25$!8OJHd{tYJfw@+&IqIcLo~S}ZXJF>*=8)pyIY1rQ zb}rNG^>dwZwL2}Dr&@J)nAOVSqxe=Dzb2U%Mymc>JKCxdmq~bxg*d_OHS%5q3d4u$ z(=2Wlk3=0OOVV$gxOgJ)G1)Np#<+Bqwz+Bwti(M(f(EWUIICmP)M5JM^4y`ZvGW?r zsZl)z+HKnA0y%4bLhUr6F7v^Ac^nN|ixM`47FEpn)4nqYs8Sidrp?wC%p*^~ka6P- zWk>PvqEpnvE5T=Vg6bdMJZE3g^%iVnAeYFzXLE zrLfC_@@EhgqoL5YnB0jIz-Qm&SrUPPuLsbYtbS{a$Utj=kZux^MR|LBp=@cegku)G zL;6A#OqsZcZ@6NSYpGF)L0XcMl4)9r4hRbjCnN_Uekj5>%`Fc!}dI>lKsD`0z0x*Fz4PJ70cbEL$wOj&a5%Z>$@Tff6qRjZl zQ&?qjI{CQ>Ho9@A1WyleJ00~P)7u^3ImkHqgtk8E#PJ|e%UY~+Hu`b}htE@E7O1j1 z0JN}qRO6B(aGm};t%9%73jDI|T+ePj{bnbMPUpy`MWc2SZb3Xo{el?yK(vc1^WDTR zF0Ktk)Cd`e)mS`EKxXe=k+WR~1U1uSEn6~bPMm-H03qEBMJ)$_0^~-7b_JW4uodi+ za2ST%bOm#eMvxOvVMTd(c+{ro6?1i0=4^)^Pu|(QE$ zl9#W=Uv0-VzJ)|#-kkQpydfcW`KBWu;Y_&gr!E{QRH)cDGd(!79f{oD(J>yu4LEmy z_*b{9S52XQ5x~`wZIz6?AET4+fR7i5EzJcQGoRx^O7-dzk8?kRnEaXgWL;hC{(3k! z%tCql-*?YHc{NKmjH+&v1<@9wOSC3C8xgZojY${xpBHb&k>Vw3IoMe z;pOfk6-)Q7#K)GSIYYdLG$sTIwb6u_H$*4>A+zRe^lj!Ud^b~=aL?E6VPIe&TxM}F zZR|l0es5ULUVE83=E z5B!K++yG>)V3U{K=5P?kzG~dZ0n2sk)@c=7e6%;b4!klEBgQN5Zrcwa!EYT57}}!S z7^|{hT3VXGm0|Q!>>9>Q)O3_kBqztR-`WL{<|9Nw1>6EE*Dbc)Subb>9iRxTy4O#R`d`CwbX$c`Q5n_gZ2FuPE|lR>yK8VK_ZT$7Pu^@ z32=-|jQF>AaA+~HAe$k75cR%z#GF3F-A3SGe?Pw{P~;d0zkP2XXBVI8uaA!gX6S?E zOG!rNGSqV$$iq5YN0HJ>o;@?|F82piJ*J@Gfy@)W_H>Rkw9MRyQ-bmX-1bK|dqt+W z^m{V0iK$}yc_*mFqxr-wwQuyV9sAH+B~8WxJkpLGB&W#YTtO_ z)!g?x88;^aSWu%dV0!%|Cc7voKM2lRLV2@r%z$Fe_{N*2NW@xd9;LD92Ma};Zp=V+ zpJ>ow00$o~5z7`;x`KBLTh<{A}^{eDY}; z0!O^&$nlotNADC7c~G)wRz?wx4^J4jkf&xB2EJT5olEb}WWUHHcjt`7^t?anJZ|fX z19k}ADznhvICU1yi4QSuwijx-64tYyuWuxBRUb&Ee~Ergl)P^+TZGE(|HwAYtIlS8 zE8ReiT1nSmwyC5^`wE;Ly` zLuoW@T4WSl!S6K;zPY@_r~7$nDcNNtT(Y%Dh>H(n?ZpDLRV{p3_Tow0rGJEIinb4V zs->m<$iAxD+QC&J67lFSHfu`J^YHSrv%9e8QN+*S)7q{gXcZ$QKQz>x>Q#P2+-;lv zq+^`Y7|-M>%I9mplc^v-71ZYqBr6qQ8xU-1;@+W5E@6`tQ8~&iB2H5L*>PvL-aS0Z zB>SC1x9G=j8MWco)ULI`9;^v#URM38%puqC@f*()j%^lhP|m1aOjZo(*MAwI{|F3|i*}KbQvLl5nwxq~mXyNeLv$A{2d`hh!CJeKF zI^n2Ft+R9eUsB)do-}Uqp*83iukgb4YFu9OOG2bj6mG1ZRsQm(#Onrf#YP+V7!lEr zd>eL9@PFM&NogJYw%i7YhavDTsp=bhYEG|{tXfQ$vz`Pli!BvnGc(@F%}?h%aedse znl5Ma^G$DP>=)&1e7-UG#uS~lZkeBr0SRvLkcE%$3J@}dn{S!1oGMA2m?&(=U=x@dehMmRit9o?9(Ug?N`jqkJEMj zE(cR8;AA62s&jIwzuvAM*lT>DDRRq$x09^zJpCPq>b9z5x zexJ7vV>xFOZni>-d=1-&<&30=$T9RbDxnDpa}OTkh6Ok-GU~t(BJcFQ)@xVXzhdph zH_|l&7l94@{Cqohs3ZHRBXVb2wM(*z*&M*Xu34pfQE6bk03Z+Ggc43p@#7M)Xi@~B z6|*0Tf*)iA=LVUA7`5b_iG+6|ycBw0H6kE0D{BI*5&`u_w3N6dp6;5`B2&R^^Q`5F z)LBw2FeA+I=l$R-zw*^n7Zcs3P_4J4TfXG^bG|KGQmZAw2w&qw3~-c0V3IhHaXEIO zO`G=5XJQ9Gk7i!IIu_w=g{JRG7B?r`6et3K$uh?0zDmx2n`Ie9!XH}lT3d0rQxv*4 zZE?G)8PH9*H*F&55BZg6NJD*@`)+Hy#S!|dcW-6^s*BsFA@w7do$rO)NUP8}--}&> zpxX-L^j-`vQL(eLOZ5uW)y6=H2L!rKjtw@#uutwIGU^LNYyz)A&^^ST6YK=1O)pA@ z0+I1^epz-&UC`>OQMmqH8Oh+5kaScI9JnrKHxL8GM-BW^Iaw#4Rc)kv3{&P~!STt8 z$vr=q1av?!JjMw*Tq=M8Hyr+)Xxy7vlUE_;Z*wyVZ|SZGT*dzZ_oxq;;k;?kb~7p& zm4Vz458O(i19>HY@m>s4xvj@y#6Dk9Q}yoZ6xhz=p##?^XsVFRDlYNu;du;Zu7;L1 z0u0g%Wr--6o+1nl0Jz-p*iE-cN3Odi`2YYz!{oPcT)1#TY;rr?)@GdR zIbwgI%iq=lS~X7lO6H1%8zxRA+6$t%?YknHAMj^2z?NYp^%*Cq#NzOoqtv=EX*y`o zy+qb|d_MW~<4>Xhaa{F)IT!aBjy7zXUPa{{$3U1tw$_3yl1iU~+sVzFtSqu^n+kB=s3ioMQS&-1 zKIW!I^GtJSK5@E*1;#J$gcD^dBXBMy=Ka=JCZ`(90T$vX>iv(p2KsKGRq#SOHVS#w zkQ6#Jlzg~w-$}2`uvWBDC8Q3Qe31&PFELAJ83!OzGq_39Y}4>wCMQqBdhSe zVUtC9S%v~t+(}^T56j-i`xyK9tJ@3j)adFsKdC7ppAd_MUqIkxc>d8)$q5`|5{?yy z8_q8wal%c{6vmxd;CZHV-^f6-3UwHSL4m5{{M`sX_?a}04G?=_MM!fA#n8X~98Rhz z^dX90!!!%y_AI+kT8wM96OJro+wmG>rELK}%@KC!85#Y#oG=U}7e2%h%%y@BUcTF_ z{2AnpZ6a18qM@8ZcLk3br{?lKC}%!N)Jkd{UQ;H!C%b3taWD!l{e;a8qZ|_l=A4Uu zAQBM3_=s{qp7O&(ca=?NeP6?X$EP6L4~>incCVnJaF*S+c;xm&M3EP8VB_Ac3An{s z|Hfmk`eiT6Yn$uNakp&Ru_OE0CKN^FO#5^>Qk($z zu1Ec77*s3gv>S8uAL!qL+rqsxn#_6QyfyiWU-@U?K4T~M7 zugt;pv)u~!rCfBt$Knx+K9!0t+Uuql16q!Sb_>$pz?pzL$H9CPp#o}DN3lCUAD;p` zbYiM^^7+cBnOqGbOBMa$)S=Uo*UGG34XU#y=M8y~q>3=s50aLZtsbv-7=;L4VW%*^ z9R>}sH$EVQ_55HP_oF%UQ3d14a6*$dK>;Xvc)r;oA(3C#gRw;S({eak&oatoEWXWU z^uds4x5zgS?}e({k+uvgM3`Hi9bJZmzaeCcKSQXxsvyyZ&sQ3JE{z#@V`~v{mZxH> z)A?rk3*+=M^UQJ!(F**ZC*u9Hiei;|+RZ1dJA3*!#Mhh8v{`IDVaFlAc=CX9rt6;7 zTL)aOnx)+_q#Do!G#m1z=$duuV&uU&TSSfB7RMo>pDz4i_ZCw^2r1!~hwqK>ERfCh zE-o%2(5Qf-K@T9#)k2DbIoimZkP;%klMM+U<6*=X73eS`*6;Q(vhGy;Fe8=T2=704 zx1d#yb;r~7orez{@(T?O-8-ogPK(enLR8DM|-wV|=^V%qR zOwLq+p@}SGOv^RL{U@uMq(8I??i$#J^HC7BX2s&S*C-V0rRnbS_2&L>Gjd$F5n&$R zUaJ2wkW7=a>fhDtunXqOl0bTgould})Y(s(sc`zS*|Y zdb4ktpL&;ZDu$nZq>hCQ&~xbW*+1MqGp&vauZot|Ju;4g)c)xgF06rjdf>;_kMdxKBd}0i& z=iBn^Nd*jweJnr-FBg|A#)s7MC+Dq~FR!fh_fsFQgRtU+mLV~R;TUubCLvt%?B0DG zA*&jy2C19D=XutNLz{bXScED*H$vnAD%^4+2G4KOwxY%_C~uB+km_|FRWx~JV10(S zA?+dULc4Y~PeY3PzNKI zNQJpL?Gn1Q{JBW!iU>5t7RQjW(V@Bc$3-V6hL?W+Lf{2Bt0w?)i!`aqAJUyq;bAW} zhUcVldQHqtg3tA~`T0su(|q>$4evkUmN`{wh_Lu+n{GFNprVGx9RiZDVor2)bS!QS zgkX%m;3JT-)KJcVIUx~A<`iOAUu$-~T>wIGoU&UH>e1mw#=`is;o_BKHduKgC ziDJ1>I%Dm8@BKt}s+%wBC}j$x^!-M}`w>TE+PTKm8|C2w-@N~yjiyid#*J;ngVans zNP&NOkhZ33eLLHuu(4*{{0epX&k`lEvAl_V+vozg@-eoN03i@oG8W0*lBn$PDd-Fs z5YJ0;!9*O9kTH8mlYH=EJ5)4K{M5-Br>D5`cXSvj$wrIze!k&^Sk5;am~h*JfV2p)i{^n;N!V(HT-Uhap@1PG`zW$I)B ziIB*BwK$b^1PGH4ftRQd(D6xRAvc;o2}QkF5b@D&`d^6zMRoBp^Mz02T)7v}gbn@e zKl*>BBtVX1ft+)aWHkk(uMd?hyGo(F^DuhQ^RY;16PG+=axAx%%h65y=lHQg!YWsI zRdY%A+t6U#DW^X=Lx5BHCH7OhJ`M7wUwbO7DsVQ%JnW~sma;ncHr2?5QFR{?b6_Dk zRGC#c@14%S5hkV4sKV&b(?>6)$M*lU3*oW)2tFN1zEpmlsB#xHRVBolrq{SS{z(Ob zbO0b*^S2tvw}r{d(_Dnf){doU#X2Zw>}@NLc_$gWFc#(iUXK$ks|c)JFe@abq;UO0 zQ7II^WyFd0%a@|U%QN#Rd$n5F(!(k#^W_Li+4ZKS+m_)V0~P1q%B;_k}l#cJd7 zQ~k%?Vy@YVyY(}!;Z}y*UikwYDiT4hz&gFeTV45uQ7Bu#U) zh?5;%6`$Js-=# zCA26ncN=!`b$D~hE8iEb@9wRLWSdpS%gbKW*GPRENRc@o^p1CB_@Ai@R2x<&qC^4%r%X*oaO41_swPHLnW`J`^N+7A4bC+2+nz zCafOg%1mDFjyx{jG`Tw(5^MaIV$Y?0Xz_Cxy8J@~?BRFl_2M0gLki;?M2{^Aqeh^R2-+7{!m=pL${6U@BaYJ3j{!K-(KgO04bq9@pxmnm7QG{ z)QRq%p6Ha66xvc`&^ueVZmmO&_ip9fRd)}{%PXBdn})PVex<9+@Ih1*sX-16Pt6>? z?0L32#gMnxM%kyQqP-AG(BgDcZf@PKe>xgtwaX% ztgufmXd?Xk9I7IW^RLAj$OSi49EiyTptvpWDwc24H1SbQN@9lIF%+$d`w0RUDXtL; z3_zs)ZX8OfFIvVTI!nF#am1AJ$&+E=5;5?19-v}Imkpp6|Mu;wM4ePs^#gi+i1+A) z3vslQc~ch18?l&N(=kEqPvqkGmt`dZsK84%jY=eMnGP|PIOY&E78i&g7r)Ap%?jmjMt761{QSiwr|Rx+U}jx*vFw`@#3Qmr3nu#b-A6&s<8l2C zfWK@lx}f>9oBaG|fd68Js z;2MRCVQDskemr#I8t&#TVn#yo zo@@)r$$C6a!eMY%%R#obZdLAi71{{NcRMJlk)fd}W`%;fX$N3UO;h%D^ZtdcPFXL1@g>GHC&8tm<4c(F~fo2gOg5&r@7 z)(15&)w5R7`JdTmv!Kh$goEEF2h=~GtdBcJOymS;gPQ!k9bJ!;}|Xx7J6dKmMt)T5Cc_RjM4^Z>N+qOXL6Z={&hQ_xK;v3>{j_&Ot`ng z^C5P3G3$4(wXa`~G#Plymj|tm%fSavAax7`@zE??`bU8EV;D9f;xKFq;lK5j3L)!I ziij43*f21OJ{DqCBej0$dsYSKm{#T9?%v}6q}G@9H%`Fx z-TK`7_taCdV94aj#B@oe+$gWbD1T6;BFxKCc@JbNja42!*KEMrotDsuaZ<;x=p1q2@?q?DA$P_J$^DT|;~h!hPq0>enDLmufmj-N!jySjKb zZrq36tcrrB&}urqc|V&yjG59en@=?B(#@iwt&VrPIX;~F18DCaR8Bv+LUhufi&TMM zTey6RL)?NsMW500irE%(X-HM#+UFpc`-Fs&_3!sUeMaK`oKqY=UM{@I35No6dmN?$ zZ;*ls0_{$LkyUt#FJHdYFY!x8Od_-qXGrwR1Rn7lN*YnffZq|OMWSWl%w$#5bsBnu zJ#dbSpljm?=Ef!DIiHEg@h>d?-67`8SWLzFIod2FEe?$+<}Y6Q2Q03* zg+6F*{EOyfuDg{Rzt35jyfapSRl-3PFz?zky@IYoawtn62sTW8{|0Cwgi&!kaI3); ztO{{s1hxtyfRS3nvFIag5dZp%F40XuDkpv%O!6ww_zG|40V;6|1KX+A)YOQb?Yd4p zz&|jzjoU*IAP4;<#DpV)rg@2}FU2SmH6Y9l!y>g(>x~TzONVNPx-05&Vmv9GC}W2G z3Lq8a)K707sh|U}0bPYrutW!Z%vr_2!0ZgT?EcpnO+(T$!3FpXn^UahJ*-JZ1f^Ggye}>{D|>Q${A0K~doAwqLFU#-Z^<|e zQ}~EUa>NRLy4@M^q8~ryv(u*5?%lj4dwp) znPz&@2}swu5r<{*7|&&&-2{ReZTMb`~5_D*VhT7QQ~$3*>wrzT4J2F01>Pe z2v~N9+St_naR&=HcQ$tV)VWm=8^!}YTlXIE@Y%D4-W_yx?Sr4~S(<;4{ZBep(hDbu z@a(!6EWFWxfQw`Zk#aD}|LdBX2>OEUKA-KkAhEdBx|x#dIGd*WQg2FbD9mlyn4#1i zU2tbI?~0bP#VGmJ5O&QTOWa5A{QL@yuFv6{%a-Hp?0g(a{9@Su4O*VrSdNeqHgo0EFlW{H&cx%mriWe_zDf(bxHoMbNM+cO3qF`{qJ&DZH?ubEz z#|V5aiVoXHt3~KQO%Xu9fk}8FE$x715b49P5hKic8cf_-A-l0ONdWLZXx^?UdOr0n z$w(kD=%h#P^E|Y-Ik#CG`@LoE9O&dq1Mv9s=1?lsfO6s?e8T6Gq>CH zeevD-2EJK7TEiM3u|orNmX1=7PZ_4u$QJoe&7(Oa?&=VdCt65>PhPjI-v6kkb-yQX z(guVN-{#k)29V5j5+K=u_+j0?N#A~UXtvwY2gRGuj(YCc9Zt6TR_PY)uCJ^1`oucv+>M+b>e}*8;7uiDcr}-|Sx-xTlt&N;OTF z_2fm9_4iu84+gm#r(0Z1dxuHn&Y-4R_-}Iiu!+fo^XD2z?>$*N&?xU_scmxX*BXIC zwU1jg@}6N9Wgi;5=l6(MG5-6@UzGiue$PGKh*uY8Y+vZ-(DbV&IznL5Kk|OktMC~o zY_RuVR_hx}N6*;9R_6YODy@i4%1@1a-!-L+-KGSr>Tgv4cITUI-afnj5|IK--Zei! zNyX@5vJ0tIc)Q6wu)E+zG9&itsKyOE~X zb&XwP{PDvsA7*SpE18n~%6$BlQEQ#sj4?C2;7Bi9slsi2Se0t@rCo16|Bph)h}xR! z9`rLni^K8ciao6n`D6CZ_(j2IPaRqt`Ir?YIXuvdPKOR&>B$1m_7Z;9?=Od*efro? zJy_9TR1!Zij53MXl$P+%b)*gH}h9ia_7uzx%VIHseak25won^K^eA6m|D~7Lao(r1+2UC z7x3Wnj5xxVgIpWENl=W~p=#$gF%OC^&2V|~x!uU`Tc!tTJh9sTPbFgsBbgLTw^geL zm9AY38kl?;=@+y26_oGKb~nx%(Q2&GZwMDj3%h7!C)-MFxSWbJ30dvx!5Gxx3{bclph6VHI? zGXIvx_CPD`cjnJ`-oPZ>L{N~4e_318wL5dr)YMdxw-}biHWI1p1sXnm1J2gwyi-AH zKle{}H{6!vvq6ZCpX}Oqu_9Nk3{r?L8KBUxwRu$7WDz(&-I;cKi*0V=lbo-LZMMH= z$n?zye&(U~%4_e+T-1jH2Mr3?zJ0=jG`Ek3^t(^12Ul|ID$`}pgOyWbMmAH})UjQ- zv%LRNWIv&^#+bo;-6*$ho4a=iP!9X>MTK$Uf@SCaPkj@cCCk1nw0d4Mu=-v}iGjCw zy{GAyeW%+GhS}h!M3iZ2+ENjA>ko?M|J^aTH{-I^?+cB;>0@jDhaUv{|1p2~%ZU1? zC*QbB{^5gsF8m-=()V8f-ADfy*Oz`eS0i`^WyvEfO0Eg3u0@M|v})3?UAva+tl=|7 zlxsLXg$aTUjza>lEh_4nYqjr|Ez$uNjy#oT81Rtp-+L*>H-{{Gjq?wbDx7eCCLZ{f zo`7;kYCINF6m8rjB*8rlM=3m}P-+9>4OsOmTIA~FKH#R)JcDRO*}?FmuuB21nG0&` zFe+M6xF6z69IERMWcB;@?PH#D@>7vswecrIA>7zQZWpXjt{nZ%cU?a4xljgt@`fs2 z5*iN6ItUl%?5&4V87^25Ng5`k?tlz)g-t+v%3DYzw)|Y+1F{>(n0mA!QXL6cOl3<> zbw|%)?B%6k7|b6i$43$8l2Z1Fu=er&SrMW28c?QE6cvJ19*d6dBZ>(I^~68AHc?b_ zDnO*9s5lkx0MRdu?!|hOilA@iK)cy5o&XdA&-6b$DfV2x8=QX_)=9_=1*3lUTqE@` z(8WAXTo7)8zr%E%M66~l{L#bE(;o9BvGG(ivyu6T#sXcJVNW-OM|Y7LW0VyM43d+_ z4kWy7`U+90M&6Uc z*=Z|ELWg*pPXop4HtNU1=cemWD~S>a9N^^b`;52%Poi}K0^s0-Sqr^OLIZnG@(I|v zv!P~?ez_1YlIWrea)zvGO1Bz?rF{#qdH@Bfj1dRL#v3^%L}zIV54CMyx7pa`NSb;2 zOW?@SyZhU6p@x-PTBy}(>YT3-{W)ltNohh+&g8Wn?$Ff1)kOHtf7kcMz%%P`e7@o7 zKG#E3Qmr})cFFvte2)W5+SJDQRIan8W$4mJS)FS>bq7sxfXPUCG`+rux2oNs6>&(S z?nOJ1^a@u__Ij8pplCL3Zu`0Z0|Nd)7RGzWKiwt#`8-X<(yy^qcua8?d-`Hj3d$CN9O+dELsBf-SxYEv4OJ@;MhkU$K=BGiYCtXc*l{2MJr$V_ zK&Q|-m=bzY>aKY@v&~u#8X9nTTGC`>Fpu+-PB07Op#L#_1XqzRL5Dh<_|N5emve;Q zw5MKUshuD<(I~K5ogwH6wD?PJ0Wbdw{zcQQ8*dVyOBB2(6j>2`cF3s&kxoJHy4* zH8-zy&xzAVnw(5Z+QcbC4(Gp6Sh~i{Ow*oORho;_huJ5m`^Y(>Tz%lQ6$j6IabVKi zktPIGH*T|2RTZ`k?yl53j1^-$J~?5hjZ)#&3Ql5^E35E=6kDK7G`F7 z%bq4a+=!B@RZbN!N;{G>KNn`or`mbbI3<#&0BN?XQ0O~$>a>vPpW)|nl8bg->g=I< zpcP*6-9JRSmbRsntjCvt9RtSCU;^7gC(S}pTuEFTU~HxE_>|x*?UiPurp*0vm*Bt$ z!I;${>^OPxbpNpUw^kz!wx_zzNIndLg(1n#kdq}g8VbFo^sT73Ra~b~hMineU_c34 zudd|C8EN7r9~N>chbJh7^v~*BOhgmuDQs zZC!Bd6;Pu))J^t&`|8Exf|QignOO>-`Z-V`gxAHk6O&U@xTWmt;R%1nG#qC9$YHs&z$W<#l!>*kBI2IbRYm32|f9_n*BEslT(uiG~67 zeO}(rKmYua6B#v3#LNFNbBT4oT~{^3cSZBBM3gG)zPXow@|}o{cAu$Ce(Ukh=kM*3GA3 z!B)LhxwtgvO8#E&pfd3)fri8O@_r~V$nZ)?icgyGPgT>LWm7zE1@0!5bWISCx6*|Qmn2$$EznA^2s*H7WO1q{OSdl3%2|DrPk(LGHxU34codbGLvlTY&(7oIczYrWxM&#$`2bN{>ATG#2 zDdjfW4OkS0a1w>c0R-|Qk^YrfEABeCstuk#Y||1Ur2cw&^r%kuNV1KF=P?0j0$Cb~ zN~NS{^=}_qba3ld^o^?`tbge1QQu`6D}raVS-TG=xPxSs_`2@H-Qtw9S@p~!Nk~8X z=p#QR@(SJKgS_`#@trl3A=m^Zg66|0$%{A~{D2JEqi+&x;?5+sQ&bL8)*ZpB0o{+9{4<{MMa_s zxG)~q&8HQ00|yB`m8im#_7I`Q?txo84!b}E23PXep~jsx`fGw30TDK86PN? zdQ#p@Iy^y?wX_>NUr8HTS>jaxCXiw&RG5P5>ycB<*byj##5$zu=Jd=-bz zi#Q&bMhSy^b^(Q5IH{xbdf3CX+?`Nx(Gn5sl3~M#Z(sVzql*li(8+1j-HLXWhGqL> zB8Dc>C}+2%tGq|2vmzmoJ>wv?<*j6|6P!kWkgKRu*Cr(;E#?dw)b$X4+pcvk*p6Y1 z^g`5&o*a(f?v$k{bdw9iY7K zu~gyj9p+tMrw)-MDkmAs2l5xXxCS(w)D8Xc`FRtNHdM8pR4!I#qR>*xCfSm9lsZKC zHOM}i9!sGspk1px2n|OX*>rzj5=;hE7T{wI$Vp=b0;y^zbVZzhB2p-43GA_^fz>F zMY(2duSyxC4k3vaowGbek(D!gqq*@F#HFL8tpY?K5O+@Rq0n!_1RBp&=b76V8d-Wn z`C!J6CQYDW6e~7O*dCNn174-YD1*qveesK)!ar4rL6~ha-K7GPY63?ZW(%g;Gbzhz zA6nF3A%K_FtRf$ievWbu?-yMOrDr9(r#@?v&Uh9)jwQLDT52ekvyVX->ne$WEPVio zY?Nmtd{6K*kImFNK|4d? zzv6brP1`pQvIky3GwV(J_3xkKJSm4;I|!?$p}fR%g1qd|x)u#_Q4~&-#D%iK=~w~FpppivS)9;>DKqRLkq=!-7^wug=^JXH@ZV~! zJ6*Lr#YK4Juo#N7@)=SuI)?23bB3+aQY@GWF{Yix>Q(!!walNVHW4XdnRSZ?yFemn z5lxt$8zE{jVJi@&GQI#A=|_L>D(%%p+_h#Nf(~Ge1@%wf`qKIyEeDxZRy*k}D!Dog zo$nqh&!7UQXXgTMtOa7#*A*grJ)Lq+%o za)W7A53|7=r0uN^ffNMF^C~874~k3sefV&h|f zmh*=u%S8x#8rs?&kJHVo=8hqc&Evs+>l&fe7d!HrMIo()Dk^6yiBhg($By#+BxJKO z1dK>f*ys_`bCW}+ySTXQ^8$bhMss9nZaxiA4#DU)l84Tqq*|L>DQ>4Rjp8^&MY__g zaAJ$(FaS2TMdl}#KD)s{9-^y@&y5qyG@H$cnKt#?txbGtMp|26BOTjc;9WnJ;Ib9j z0o7J`=b>x+_q2GmRIov5Ak%3KC$2F^16iOwiQC^^X<}JyLV9c6YI-)-BAg61?%OI^ zxwM6arh_vKtG+Lb=Z!jczrR~2MVJ=Upjvcv>PkZC*EM8YtS+6| z7O;@S(*{c1NHt#>3aTfb2%$f83~2r*a40l8o=u?hd^T&x3}L zJ6BS#2WGs8Us!-ZDTK%{AZ|+svk4^oa>lkX@yH&*JH_UtstO3n+OU6*#;@>+5PO}5m+QS9v-WO*LVr68wRLGO6>ZAO;UJj7mPvMc9PT#cWv9rSff$eAes4v3Drh**<~rIJh!zQ{ zt3~+Dhp&G7VuVU)6=n6SUqh99c~kFx(r)O30$Z~;Nr9F$2pWbO6;@P_0p<-uW4V0h z$3q%N$$vdG5Yfhpt*8VHzU+KKK|%5$HaFaW{FHa_s6Jl2eTvqg%M>tDo$ndA{A#|W zJun3&Z^BnjR5n_7mU6oEuad>Gtd6Z)AK*fS7XmnX-QHoU{i8ZWUp8z{i!p0lw`A9) zC9nGx)(emzx*?*!>6sJ=b^;>uOLm~24Q2&`pmj%8O%b_Xs1rcg5wKcZS9=Ko67}Jt z=Y@mURS~;^_(wPR_FdjRsdQeiO&sr3LV?^XD;c)yj#2wH%gE^#zsm2k(bKni!vF+- z{+qp=>nW^KvBE-{oXzI+lB(-k@+Fkp7k@McYrVo9go0A*EZ+@j!v+E13Lq{qcEs*y zF4rrWfwF{pC_eN2q9bet(?qW*B}V*0Crk|T1b$z~7ZBtONH-0;Rgjv^fCQBuFhE zB26Q`%zEJEx$L=Q3hMayczR30Kx<>zMi2`AYq2pUpXl{bI}48t&MvAflwd~qvq@D4 zDV5^k2FxOi^iFljtYxsDaIgvIGWcJ7p!Qxx=cJ*^6s0OszgXm(ps8FbIsLh!ZT@P#CaHY=`w`OdTU4* z-;G4u1I+QMPg@$59DfpN7ub8MR$TQnmhYK-rk6d<4o$D}Svzq~k-D(7%yqFBRZeW{ zBU5`Sm1oSG-C&{{Ene;_eIRkulywN_XJrj#lu`vY86N$s0?4Yt@8Zpn<6I%YaHYnjSrW-36|edn{?3vkDIjQXFx#6z^cwI2aLLlM*uw+}*-f2~G2d zAF`Ori>P;2*6>qST6?@P4p42vp^)od^`U8{mN#1@T;w!zb{o)THOF# zt;pT;tm(7bJ`n}2RkQ5KV>w7-Srv%bN?bBBEM5CwP_w{E->8|`;Jcgr0|CLeAG zF&D&LOk|~wme5T=`e$tJA=Hglq=hGnjG@=D`O~Kp=gkKAf*d?2c|Psba88f{d3_1d zd7AA$zaE}dFPk=Ps!~JXpX!$8h*^*FAkE6EW8$$!P9(yPpMLu3LaAfpa1{SW?C{?q z8WgS>1@ac=c{i#2#lodhnCa?{*p;z4I|UBBEO`b{f*{2XjJkN)$~MK1P;5Rw)l9pV z>0gQIOF9Ot68eS5%v;4(8Auh9kT@QTlPIA4S5mB8xcfL`OKemW%TOI6`wmNN{P5xD zbUXEB=O^8vwp}!PTYKM_#;bPRvo7{mCYa9?Qy$o;Zr5CEIQeO)WEx5l7?UcUREnSU zk@$xz;XEE)@p5i0xLrG-JUsXicob-+wP)~|O`)K>;|QWI-T`_V}J* z*iU{wkNGbaDOj_M}t&s}bLt6EY!LZyA4h@)v?jK{Yk3oX9-B z3S@K88((Ys30rAM$}eC;x%ds>{1=?P+2wu$lwV!i&yr`cxsLPjh=!cPNcIxCqaaHn zyk3v^hr0F)`RHM4=0h3gs%AR*RI|-cUW8#0df0VRKZhbH|DrLJ^BI<^*8&d z@xp@zNy0WsAbe_|TQGGRUuRE=4CuX=jn#iq9;jg?oX#Em-p7z{yPo;K@zH-4f&LfQ g_y0d6w6b2&$?es`N$tCRERRVwY|K}YL#BWCKkb)2W&i*H literal 0 HcmV?d00001 From 1a93d94d1454c4996b40f3fe77cc1946bdab7bf6 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Mon, 29 Jun 2026 11:33:14 -0400 Subject: [PATCH 15/23] Remove local Auto-FL validation scaffolding Signed-off-by: Holger Roth --- docs/resources/autofl_skill_progress.png | Bin 267673 -> 0 bytes research/auto-fl-research/.gitignore | 2 - research/auto-fl-research/README.md | 2 +- .../data/cifar10_data_utils.py | 13 +- research/auto-fl-research/program.md | 31 +- .../scripts/campaign_guard.py | 290 ------------------ .../auto-fl-research/scripts/extract_score.py | 2 +- .../scripts/plateau_watchdog.py | 2 +- .../skills/autofl-nvflare/SKILL.md | 16 +- .../autofl-nvflare/references/runbook.md | 10 +- .../auto-fl-research/tasks/cifar10/client.py | 54 ++-- .../tasks/cifar10/mutation_schema.yaml | 13 +- .../auto-fl-research/tasks/cifar10/profile.md | 17 +- skills/nvflare-autofl/README.md | 97 ------ skills/nvflare-autofl/SKILL.md | 37 +-- .../references/continuous-campaigns.md | 8 +- .../scripts/launch_h100_skill_trial.sh | 213 ------------- .../scripts/run_job_campaign.py | 55 ++-- skills/nvflare-autofl/tests/helper_scripts.md | 10 +- tests/unit_test/research/__init__.py | 13 - .../research/autofl_campaign_guard_test.py | 129 -------- .../research/autofl_extract_score_test.py | 41 --- .../tool/autofl_skill_runner_test.py | 4 +- 23 files changed, 100 insertions(+), 959 deletions(-) delete mode 100644 docs/resources/autofl_skill_progress.png delete mode 100755 research/auto-fl-research/scripts/campaign_guard.py delete mode 100644 skills/nvflare-autofl/README.md delete mode 100755 skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh delete mode 100644 tests/unit_test/research/__init__.py delete mode 100644 tests/unit_test/research/autofl_campaign_guard_test.py delete mode 100644 tests/unit_test/research/autofl_extract_score_test.py diff --git a/docs/resources/autofl_skill_progress.png b/docs/resources/autofl_skill_progress.png deleted file mode 100644 index 2ec9be10238751a72f55e918684aecaa6bf4c2d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 267673 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⁣>{p-aR|M~wzwl~S2)_j6Z6CD=mgi3;y|8qtD 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�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�tw{h1X6=0FkCWINF? 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 diff --git a/research/auto-fl-research/.gitignore b/research/auto-fl-research/.gitignore index 1161b039bc..07ad9505c0 100644 --- a/research/auto-fl-research/.gitignore +++ b/research/auto-fl-research/.gitignore @@ -13,5 +13,3 @@ run.log .DS_Store .venv progress.png -.autoresearch/ -campaign_state.json diff --git a/research/auto-fl-research/README.md b/research/auto-fl-research/README.md index b240fbb254..738a6d2a11 100644 --- a/research/auto-fl-research/README.md +++ b/research/auto-fl-research/README.md @@ -54,7 +54,7 @@ QWBE is currently implemented as an **instruction and artifact workflow**, not a The current flow is: -1. Run `scripts/plateau_watchdog.py results.tsv` after finalizing each batch. Its default hard trigger is 8 scored non-crash candidates without a material improvement or literature reset. Treat this as the normal trigger for literature mode. +1. Run `scripts/plateau_watchdog.py results.tsv` after finalizing each batch. Its default hard trigger is 32 scored non-crash candidates without a material improvement or literature reset. Treat this as the normal trigger for literature mode. 2. Start a literature-review timer with `scripts/log_literature_review.py --start`, then generate source-backed proposal cards from recent `results.tsv` symptoms and relevant papers. 3. Filter out duplicates, known null/worse ideas, and proposals that violate the current contract. 4. Score each remaining proposal from 1-5 on expected gain, contract safety, simplicity, evidence, novelty, and runtime cost. diff --git a/research/auto-fl-research/data/cifar10_data_utils.py b/research/auto-fl-research/data/cifar10_data_utils.py index 2c392d0d1e..fd25e5e186 100644 --- a/research/auto-fl-research/data/cifar10_data_utils.py +++ b/research/auto-fl-research/data/cifar10_data_utils.py @@ -44,7 +44,6 @@ def get_site_class_summary(train_label, site_idx): def create_datasets(site_name, train_idx_root, central=False): - """Return a site-local CIFAR-10 train split and the global CIFAR-10 test set.""" transform_train = transforms.Compose( [ transforms.ToTensor(), @@ -85,17 +84,17 @@ def create_datasets(site_name, train_idx_root, central=False): download=True, transform=transform_train, ) - test_dataset = torchvision.datasets.CIFAR10( + valid_dataset = torchvision.datasets.CIFAR10( root=CIFAR10_ROOT, train=False, download=True, transform=transform_valid, ) - return train_dataset, test_dataset + return train_dataset, valid_dataset -def create_data_loaders(train_dataset, eval_dataset, batch_size, num_workers): +def create_data_loaders(train_dataset, valid_dataset, batch_size, num_workers): train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=batch_size, @@ -104,12 +103,12 @@ def create_data_loaders(train_dataset, eval_dataset, batch_size, num_workers): pin_memory=True, persistent_workers=True if num_workers > 0 else False, ) - eval_loader = torch.utils.data.DataLoader( - eval_dataset, + valid_loader = torch.utils.data.DataLoader( + valid_dataset, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True, persistent_workers=True if num_workers > 0 else False, ) - return train_loader, eval_loader + return train_loader, valid_loader diff --git a/research/auto-fl-research/program.md b/research/auto-fl-research/program.md index b57d7e859b..47c6a0f7ed 100644 --- a/research/auto-fl-research/program.md +++ b/research/auto-fl-research/program.md @@ -250,24 +250,10 @@ launching more candidates. If it prints `recommendation=continue`, keep iterating locally unless repeated crashes share one root cause or no non-duplicate safe axis remains. -11. Run the campaign guard and obey its machine-readable stop/continue state: - - ```bash - "${PYTHON}" scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json --format json - ``` - -The guard owns whether a final response is allowed. If it writes -`"final_response_allowed": false`, do not produce a final answer: execute the -reported `next_action` immediately. Reports, plots, commits, watchdog checks, -and encoded `job.py` defaults are checkpoints, not stop conditions, unless the -guard reports a stop state. - ## Never Stop Once setup and the initial baseline are complete, continue autonomously until -manually interrupted. Do not pause to ask whether to keep going. The -deterministic stop authority is `scripts/campaign_guard.py`; run it after every -batch/checkpoint and before any final response. +manually interrupted. Do not pause to ask whether to keep going. Cycle through: @@ -279,15 +265,13 @@ Cycle through: - finalize `results.tsv` statuses - run the plateau watchdog - switch to literature mode when triggered -- run `scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json` - commit surviving code and ledger checkpoints - repeat If local ideas run out, inspect recent near-misses in `results.tsv`, reread the task profile and its `mutation_schema.yaml`, combine compatible kept settings, or switch to the literature loop. Stay within the hard invariants and active -budget. If the guard says `decision=continue`, choose a next axis even when the -current best stack has been encoded into defaults and verified. +budget. ## Literature Loop @@ -405,14 +389,3 @@ At checkpoints, generate a compact progress plot with: ```bash "${PYTHON:-python3}" scripts/plot_progress.py results.tsv --output progress.png ``` - -Then refresh the campaign state: - -```bash -"${PYTHON:-python3}" scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json -``` - -Only write a final run report after the guard prints -`final_response_allowed=true`, for example because the human created a stop -file, an explicit candidate cap was exhausted, or a hard repeated-failure -blocker prevents comparable runs. diff --git a/research/auto-fl-research/scripts/campaign_guard.py b/research/auto-fl-research/scripts/campaign_guard.py deleted file mode 100755 index 1bee21bc17..0000000000 --- a/research/auto-fl-research/scripts/campaign_guard.py +++ /dev/null @@ -1,290 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Write the deterministic Auto-FL campaign continuation state. - -This script owns the stop/continue decision for the research harness. Agents may -choose mutations, but they must not decide that a campaign is complete while this -guard says another comparable batch should run. -""" - -from __future__ import annotations - -import argparse -import csv -import json -import math -import subprocess -import sys -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -DEFAULT_MAX_SCORED_SINCE_RESET = 8 -DEFAULT_MIN_DELTA = 0.0005 -DEFAULT_STATE_PATH = ".autoresearch/campaign_state.json" -DEFAULT_STOP_FILES = ("STOP_AUTOFL", ".autoresearch/STOP_AUTOFL", ".nvflare/autofl/STOP") -COMPARABLE_STATUSES = {"candidate", "keep", "discard", "crash"} - - -def utc_now() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - - -def parse_score(value: str) -> float | None: - try: - score = float(value) - except (TypeError, ValueError): - return None - if math.isnan(score) or math.isinf(score): - return None - return score - - -def load_rows(path: Path) -> list[dict[str, str]]: - if not path.exists(): - return [] - with path.open("r", encoding="utf-8", newline="") as f: - return list(csv.DictReader(f, delimiter="\t")) - - -def normalize_status(row: dict[str, str]) -> str: - return (row.get("status", "") or "").strip().lower() - - -def is_baseline(row: dict[str, str]) -> bool: - text = " ".join( - [ - row.get("description", "") or "", - row.get("budget", "") or "", - row.get("artifacts", "") or "", - ] - ).lower() - return "baseline" in text or "--name baseline" in text - - -def comparable_attempts(rows: list[dict[str, str]]) -> list[dict[str, str]]: - return [row for row in rows if normalize_status(row) in COMPARABLE_STATUSES and not is_baseline(row)] - - -def scored_attempts(rows: list[dict[str, str]]) -> list[dict[str, str]]: - return [row for row in comparable_attempts(rows) if parse_score(row.get("score", "")) is not None] - - -def pending_candidates(rows: list[dict[str, str]]) -> list[dict[str, str]]: - return [row for row in rows if normalize_status(row) == "candidate"] - - -def best_score(rows: list[dict[str, str]], mode: str) -> float | None: - scores = [parse_score(row.get("score", "")) for row in rows] - scores = [score for score in scores if score is not None] - if not scores: - return None - return min(scores) if mode == "min" else max(scores) - - -def parse_max_candidates(value: str | None) -> int | None: - if value is None or str(value).strip() == "": - return None - try: - parsed = int(value) - except (TypeError, ValueError): - return None - if parsed <= 0: - return None - return parsed - - -def parse_max_candidates_arg(value: str) -> int: - parsed = parse_max_candidates(value) - if parsed is None: - raise argparse.ArgumentTypeError("must be a positive integer") - return parsed - - -def existing_stop_files(paths: list[str]) -> list[str]: - return [path for path in paths if Path(path).exists()] - - -def repeated_crash_blocker(attempts: list[dict[str, str]], threshold: int) -> bool: - if threshold <= 0 or len(attempts) < threshold: - return False - return all(normalize_status(row) == "crash" for row in attempts[-threshold:]) - - -def parse_key_value_output(text: str) -> dict[str, str]: - values = {} - for line in text.splitlines(): - if "=" not in line: - continue - key, value = line.split("=", 1) - values[key.strip()] = value.strip() - return values - - -def run_watchdog(results_path: Path, threshold: int, min_delta: float) -> dict[str, Any]: - watchdog = Path(__file__).resolve().with_name("plateau_watchdog.py") - if not watchdog.exists() or not results_path.exists(): - return {"available": False, "recommendation": "continue", "raw": ""} - - command = [ - sys.executable, - str(watchdog), - str(results_path), - "--max-scored-since-reset", - str(threshold), - "--min-delta", - str(min_delta), - ] - process = subprocess.run(command, text=True, capture_output=True, check=False) - raw = process.stdout.strip() - parsed = parse_key_value_output(raw) - recommendation = parsed.get("recommendation") or "continue" - return { - "available": True, - "returncode": process.returncode, - "recommendation": recommendation, - "fields": parsed, - "raw": raw, - "error": process.stderr.strip(), - } - - -def guard_state(args) -> dict[str, Any]: - results_path = Path(args.results) - rows = load_rows(results_path) - attempts = comparable_attempts(rows) - pending = pending_candidates(rows) - cap = args.max_candidates - cap_source = "explicit" if cap is not None else "uncapped" - stop_files = existing_stop_files(args.stop_file) - watchdog = run_watchdog(results_path, args.plateau_threshold, args.min_delta) - - decision = "continue" - reason = "continue" - next_action = "launch_next_candidate_batch" - final_response_allowed = False - - if pending: - reason = "pending_candidates" - next_action = "finalize_pending_candidates" - elif stop_files: - decision = "stop" - reason = "manual_stop_file" - next_action = "final_report" - final_response_allowed = True - elif cap is not None and len(attempts) >= cap: - decision = "stop" - reason = "candidate_cap_exhausted" - next_action = "final_report" - final_response_allowed = True - elif repeated_crash_blocker(attempts, args.hard_crash_threshold): - decision = "stop" - reason = "hard_repeated_crash_blocker" - next_action = "final_report" - final_response_allowed = True - elif watchdog.get("recommendation") == "literature": - reason = "plateau_literature" - next_action = "run_literature_loop" - - if final_response_allowed: - instruction = "Final report is allowed because the campaign guard reached a stop condition." - elif next_action == "finalize_pending_candidates": - instruction = ( - "Do not produce a final answer. Finalize reviewed candidate rows, refresh artifacts, then rerun the guard." - ) - elif next_action == "run_literature_loop": - instruction = "Do not produce a final answer. Run the literature loop, log the event, and launch source-backed candidates." - else: - instruction = "Do not produce a final answer. Launch the next same-budget candidate batch now." - - return { - "schema_version": "nvflare.autofl.campaign_state.v1", - "updated_at": utc_now(), - "results": str(results_path), - "decision": decision, - "reason": reason, - "next_action": next_action, - "final_response_allowed": final_response_allowed, - "candidate_cap": cap, - "candidate_cap_source": cap_source, - "candidate_attempts": len(attempts), - "pending_candidates": len(pending), - "scored_attempts": len(scored_attempts(rows)), - "best_score": best_score(rows, args.mode), - "stop_files": stop_files, - "watchdog": watchdog, - "agent_instruction": instruction, - } - - -def write_state(path: Path, state: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = path.with_suffix(path.suffix + ".tmp") - tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") - tmp_path.replace(path) - - -def print_text(state: dict[str, Any]) -> None: - for key in [ - "decision", - "reason", - "next_action", - "final_response_allowed", - "candidate_cap", - "candidate_cap_source", - "candidate_attempts", - "pending_candidates", - "scored_attempts", - "best_score", - "agent_instruction", - ]: - value = state.get(key) - if isinstance(value, bool): - value = str(value).lower() - elif value is None: - value = "" - print(f"{key}={value}") - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("results", nargs="?", default="results.tsv") - parser.add_argument("--state", default=DEFAULT_STATE_PATH) - parser.add_argument("--max-candidates", type=parse_max_candidates_arg) - parser.add_argument("--stop-file", action="append", default=list(DEFAULT_STOP_FILES)) - parser.add_argument("--plateau-threshold", type=int, default=DEFAULT_MAX_SCORED_SINCE_RESET) - parser.add_argument("--min-delta", type=float, default=DEFAULT_MIN_DELTA) - parser.add_argument("--hard-crash-threshold", type=int, default=6) - parser.add_argument("--mode", choices=["max", "min"], default="max") - parser.add_argument("--format", choices=["text", "json"], default="text") - args = parser.parse_args() - - if args.plateau_threshold <= 0: - raise ValueError("--plateau-threshold must be positive") - if args.min_delta < 0: - raise ValueError("--min-delta must be non-negative") - - state = guard_state(args) - write_state(Path(args.state), state) - if args.format == "json": - print(json.dumps(state, sort_keys=True)) - else: - print_text(state) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/research/auto-fl-research/scripts/extract_score.py b/research/auto-fl-research/scripts/extract_score.py index 3c6b5f93d9..87880b1848 100755 --- a/research/auto-fl-research/scripts/extract_score.py +++ b/research/auto-fl-research/scripts/extract_score.py @@ -23,7 +23,7 @@ from pathlib import Path PRIMARY_MODEL_KEY = "SRV_FL_global_model.pt" -METRIC_KEYS = ["test_accuracy", "accuracy", "val_accuracy", "token_f1"] +METRIC_KEYS = ["accuracy", "val_accuracy", "test_accuracy", "token_f1"] def find_json(result_root: Path) -> Path: diff --git a/research/auto-fl-research/scripts/plateau_watchdog.py b/research/auto-fl-research/scripts/plateau_watchdog.py index 09ae364ac6..af9f15327b 100644 --- a/research/auto-fl-research/scripts/plateau_watchdog.py +++ b/research/auto-fl-research/scripts/plateau_watchdog.py @@ -25,7 +25,7 @@ from dataclasses import dataclass from pathlib import Path -DEFAULT_MAX_SCORED_SINCE_RESET = 8 +DEFAULT_MAX_SCORED_SINCE_RESET = 32 DEFAULT_MIN_DELTA = 0.0005 NON_PLATEAU_STATUSES = {"crash", "literature"} ASSIGNMENT_PATTERN = re.compile(r"\b([A-Za-z][A-Za-z0-9_+-]*)=([^\s,;\]\)]+)") diff --git a/research/auto-fl-research/skills/autofl-nvflare/SKILL.md b/research/auto-fl-research/skills/autofl-nvflare/SKILL.md index 97c4540399..bc17884190 100644 --- a/research/auto-fl-research/skills/autofl-nvflare/SKILL.md +++ b/research/auto-fl-research/skills/autofl-nvflare/SKILL.md @@ -1,6 +1,6 @@ --- name: autofl-nvflare -description: Help coding agents work on an NVFlare-based Auto-FL harness that follows an autoresearch-style loop. Use when the user wants to create, edit, debug, or extend program.md, task folders such as tasks/cifar10/ and tasks/vlm_med/, task-local job.py, client.py, model.py, shared custom_aggregators.py, mutation policies, results.tsv logging, or coding-agent prompts for a guardrailed federated-learning research loop. This skill is specifically for NVFlare harness work where the Client API loop, DIFF upload contract, and NUM_STEPS_CURRENT_ROUND metadata must stay intact unless the user explicitly asks for a protocol upgrade. +description: Help coding agents work on an NVFlare-based Auto-FL harness that follows an autoresearch-style loop. Use when the user wants to create, edit, debug, or extend program.md, task folders such as tasks/cifar10/ and tasks/vlm_med/, task-local job.py, client.py, model.py, shared custom_aggregators.py, mutation policies, results.tsv logging, or coding-agent prompts for a bounded federated-learning research loop. This skill is specifically for NVFlare harness work where the Client API loop, DIFF upload contract, and NUM_STEPS_CURRENT_ROUND metadata must stay intact unless the user explicitly asks for a protocol upgrade. --- # autofl-nvflare @@ -76,17 +76,9 @@ After making edits: 12. commit that ledger on the active `autoresearch/` branch after baseline and completed runs/checkpoints, and commit surviving code changes as soon as they are kept rather than carrying them uncommitted into the next batch 13. if a candidate implements a paper-derived method, include a compact source ref in the `results.tsv` description field and fuller citation details in `templates/mutation_report.md` 14. rank the completed batch against the ledger before deciding whether to keep, narrow, or revert; rank primarily by score, use runtime as a coarse secondary signal, and prefer the faster/simpler candidate when scores are within noise -15. if the user gives an explicit `N`-candidate budget, count up to `N` comparable candidate attempts after the baseline. Do not count deterministic import, validation, smoke, plotting, reporting, the baseline, or infrastructure-only retries caused by sandbox/socket/runtime setup. Count a real candidate crash once the candidate run starts under the intended execution environment -16. if the user does not give an explicit candidate cap, run the original autoresearch loop: after setup and baseline, continue launching same-budget candidate batches until manually interrupted; do not invent a default stopping point, do not ask whether to keep going, and do not send a final response while safe comparable candidates remain. Progress updates in uncapped mode must be status observations, not "should I continue?" questions; continue unless the human explicitly interrupts or the campaign guard allows finalization -17. after every finalized batch, run `scripts/plateau_watchdog.py results.tsv`; treat plateau as a decision checkpoint, not an automatic stop. If it prints `recommendation=literature`, run the Camyla-inspired literature loop from `program.md`, record the `literature` event row in `results.tsv`, refresh `progress.png`, and launch the top compatible candidate batch next unless the user asked to stop. If it prints `recommendation=continue`, refresh `progress.png` and keep iterating locally unless repeated crashes share one root cause or no non-duplicate safe axis remains -18. after every finalized batch, report checkpoint, refreshed plot, local commit, encoded default verification, cap check, or possible stop point, run `"${PYTHON:-python3}" scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json --format json` -19. treat `.autoresearch/campaign_state.json` as authoritative campaign state. If `final_response_allowed` is `false`, do not produce a final answer; execute the returned `next_action` immediately. If `next_action` is `finalize_pending_candidates`, finalize the reviewed rows and rerun the guard. If it is `run_literature_loop`, run the literature loop and launch source-backed candidates. If it is `launch_next_candidate_batch`, choose the next same-budget axis and launch it -20. never treat a kept improvement, refreshed report, refreshed `progress.png`, encoded `job.py` defaults, or local commit as a stopping condition in an uncapped campaign; these are checkpoints before the next same-budget batch unless `campaign_guard.py` says `final_response_allowed=true` -21. report the mutation hypothesis, changed files, commands run, observed outcome, literature basis, run analysis, and next mutation; then launch the next candidate batch when the guard says to continue -22. refresh the progress plot after every finalized batch, cap exhaustion, manual stop, plateau checkpoint, or hard-blocker checkpoint with `"${PYTHON:-python3}" scripts/plot_progress.py results.tsv --output progress.png`; if matplotlib cache paths fail, retry with writable `MPLCONFIGDIR` and `XDG_CACHE_HOME` -23. if an active NVFLARE simulator candidate stalls on a hard child-process connection failure such as `Failed to create connection to the child process in SimulatorClientRunner`, or on a configured simulator no-progress watchdog after a round is dispatched but server/client progress markers stop advancing, recover inside the same campaign: mark only that candidate as `crash`, terminate the stuck `job.py` child if needed, refresh `results.tsv`, `progress.png`, and `.autoresearch/campaign_state.json`, then continue with the next same-budget candidate. Do not start a new campaign directory, new branch, new baseline, new objective, or final report unless the human explicitly asked to reset -24. do not treat a quiet NVFLARE server log as a stall by itself. After a round is dispatched, inspect `/tmp/nvflare/simulation//site-*/log.txt` or `site-*/log_fl.txt` for client epoch, finished-training, download, or task-completion progress before interrupting. If any client log or server aggregation marker advances within the expected candidate runtime, keep waiting on the same candidate; never stop the runner, final-answer, or start a new campaign for that pattern -25. only produce a final answer when `scripts/campaign_guard.py` reports `final_response_allowed=true`; then end with reproducible artifacts: finalized `results.tsv`, refreshed `progress.png`, `.autoresearch/campaign_state.json`, and a concise report or `templates/mutation_report.md` entry covering the baseline, best score, artifacts, command provenance, failures, product friction, and next mutation. The final answer must include absolute paths to `autofl.yaml` when present, `results.tsv`, `progress.png`, `.autoresearch/campaign_state.json`, and any report artifact, plus the best candidate, metric improvement, and whether the guard stopped because of a cap, manual interruption, or hard blocker +15. after setup and baseline, continue launching same-budget candidate batches until manually interrupted; do not ask whether to keep going +16. after every finalized batch, run `scripts/plateau_watchdog.py results.tsv`; if it prints `recommendation=literature`, stop local jitter sweeps and run the Camyla-inspired literature loop from `program.md`: time it with `scripts/log_literature_review.py --start` / `--finish`, generate diverse queries, triage primary papers, extract challenge cards, score contract-safe proposals in `templates/literature_loop.md`, record the `literature` event row in `results.tsv`, and launch the top compatible candidate batch next; if it prints `recommendation=continue`, do not log another literature row for a routine missed batch, and keep iterating locally unless repeated crashes share one root cause or no non-duplicate safe axis remains +17. report the mutation hypothesis, changed files, commands run, observed outcome, literature basis, run analysis, and next mutation ## References diff --git a/research/auto-fl-research/skills/autofl-nvflare/references/runbook.md b/research/auto-fl-research/skills/autofl-nvflare/references/runbook.md index 96a3f18664..d7038f9406 100644 --- a/research/auto-fl-research/skills/autofl-nvflare/references/runbook.md +++ b/research/auto-fl-research/skills/autofl-nvflare/references/runbook.md @@ -19,12 +19,10 @@ 16. Finalize reviewed statuses before starting the next batch: promote the selected survivor to `keep`, mark reviewed non-survivors as `discard`, leave crashes as `crash`, and leave only unresolved active rows as `candidate`. Prefer `"${PYTHON}" scripts/finalize_batch_status.py results.tsv --last "${PARALLEL_CANDIDATES:-4}" --keep-best --discard-others`. 17. If a candidate implements a paper-derived method, include a compact source ref in the `results.tsv` description field and fuller citation details in `templates/mutation_report.md`. 18. Run `"${PYTHON}" scripts/plateau_watchdog.py results.tsv` after every finalized batch. If it prints `recommendation=literature`, stop local jitter sweeps and run a literature-grounded proposal loop before launching more candidates. If it prints `recommendation=continue`, keep iterating locally unless repeated crashes share one root cause or no non-duplicate safe axis remains. -19. Refresh deterministic campaign state with `"${PYTHON}" scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json --format json` after every finalized batch, report update, plot refresh, encoded default verification, or possible stop point. -20. Treat `.autoresearch/campaign_state.json` as authoritative. If `final_response_allowed=false`, do not summarize as final; execute `next_action` immediately. If `next_action=finalize_pending_candidates`, finalize reviewed rows and rerun the guard. If `next_action=run_literature_loop`, run the literature loop and launch source-backed candidates. If `next_action=launch_next_candidate_batch`, choose and launch the next same-budget batch. -21. Commit `results.tsv` on the active `autoresearch/` branch after baseline and each completed run/checkpoint. Commit surviving code changes as soon as they are kept; do not carry kept changes uncommitted into the next batch. -22. Continue with the next same-budget candidate batch until `campaign_guard.py` reports `final_response_allowed=true`; do not ask whether to keep going after setup and baseline. -23. When the watchdog fires, or when repeated crashes share one root cause and a source-backed fix is needed before more runs are useful, run the literature loop: start timing with `"${PYTHON}" scripts/log_literature_review.py --start --description "plateau after : "`, search papers, extract challenges, score contract-safe ideas, append the `literature` event with `--finish`, and launch the top compatible candidate batch next. -24. Summarize the result only when the guard reports `final_response_allowed=true`. +19. Commit `results.tsv` on the active `autoresearch/` branch after baseline and each completed run/checkpoint. Commit surviving code changes as soon as they are kept; do not carry kept changes uncommitted into the next batch. +20. Continue with the next same-budget candidate batch until manually interrupted; do not ask whether to keep going after setup and baseline. +21. When the watchdog fires, or when repeated crashes share one root cause and a source-backed fix is needed before more runs are useful, run the literature loop: start timing with `"${PYTHON}" scripts/log_literature_review.py --start --description "plateau after : "`, search papers, extract challenges, score contract-safe ideas, append the `literature` event with `--finish`, and launch the top compatible candidate batch next. +22. Summarize the result when interrupted or when reporting a checkpoint. ## Single-H100 mode For the default CIFAR-10/H100 profile, run same-budget candidate batches via `PYTHON=.venv/bin/python TASK_DIR=tasks/cifar10 bash scripts/run_iteration.sh`, with unique `RUN_LOG` and `--name` values for each concurrent candidate. Default to `PARALLEL_CANDIDATES=4`, and reduce the width if CUDA memory or host contention appears. For other profiles, use that profile's hardware, environment, and candidate-width rules. diff --git a/research/auto-fl-research/tasks/cifar10/client.py b/research/auto-fl-research/tasks/cifar10/client.py index a29b077e75..793b40bbdd 100644 --- a/research/auto-fl-research/tasks/cifar10/client.py +++ b/research/auto-fl-research/tasks/cifar10/client.py @@ -158,7 +158,7 @@ def _make_generator(seed): def _create_seeded_data_loaders( train_dataset, - test_dataset, + valid_dataset, batch_size, eval_batch_size, num_workers, @@ -174,8 +174,8 @@ def _create_seeded_data_loaders( worker_init_fn=_seed_worker if num_workers > 0 else None, generator=_make_generator(seed), ) - test_loader = torch.utils.data.DataLoader( - test_dataset, + valid_loader = torch.utils.data.DataLoader( + valid_dataset, batch_size=eval_batch_size, shuffle=False, num_workers=num_workers, @@ -184,7 +184,7 @@ def _create_seeded_data_loaders( worker_init_fn=_seed_worker if num_workers > 0 else None, generator=_make_generator(seed + 1), ) - return train_loader, test_loader + return train_loader, valid_loader def _zero_scaffold_controls(model): @@ -304,13 +304,13 @@ def main(args): criterion_prox = PTFedProxLoss(mu=args.fedproxloss_mu) print(f"Creating datasets for site={site_name}") - train_dataset, test_dataset = create_datasets( + train_dataset, valid_dataset = create_datasets( site_name, train_idx_root=args.train_idx_root, ) - train_loader, test_loader = _create_seeded_data_loaders( + train_loader, valid_loader = _create_seeded_data_loaders( train_dataset, - test_dataset, + valid_dataset, batch_size=args.batch_size, eval_batch_size=args.eval_batch_size, num_workers=args.num_workers, @@ -343,21 +343,18 @@ def main(args): if flare.is_evaluate(): print(f"{site_name}: cross-site evaluation task") - test_acc_global_model = evaluate(model, test_loader, DEVICE) - print(f"{site_name}: global CIFAR-10 test accuracy={100 * test_acc_global_model:.2f}%") + val_acc_global_model = evaluate(model, valid_loader, DEVICE) + print(f"{site_name}: global validation accuracy={100 * val_acc_global_model:.2f}%") summary_writer.add_scalar( - tag="test_acc_global_model", - scalar=test_acc_global_model, + tag="val_acc_global_model", + scalar=val_acc_global_model, global_step=current_round, ) # Cross-site validation expects a metrics-only DXO (DataKind.METRICS). # Sending no params lets FLModelUtils emit DataKind.METRICS instead of WEIGHT_DIFF. flare.send( flare.FLModel( - metrics={ - "accuracy": test_acc_global_model, - "test_accuracy": test_acc_global_model, - }, + metrics={"accuracy": val_acc_global_model}, meta={"NUM_STEPS_CURRENT_ROUND": 0}, ) ) @@ -378,13 +375,12 @@ def main(args): metrics = {} if args.eval_global_every_round: - test_acc_global_model = evaluate(global_model, test_loader, DEVICE) - metrics["accuracy"] = test_acc_global_model - metrics["test_accuracy"] = test_acc_global_model - print(f"{site_name}: global CIFAR-10 test accuracy={100 * test_acc_global_model:.2f}%") + val_acc_global_model = evaluate(global_model, valid_loader, DEVICE) + metrics["accuracy"] = val_acc_global_model + print(f"{site_name}: global validation accuracy={100 * val_acc_global_model:.2f}%") summary_writer.add_scalar( - tag="test_acc_global_model", - scalar=test_acc_global_model, + tag="val_acc_global_model", + scalar=val_acc_global_model, global_step=current_round, ) @@ -450,11 +446,11 @@ def main(args): ) if args.evaluate_local: - test_acc_local_model = evaluate(model, test_loader, DEVICE) - print(f"{site_name}: local CIFAR-10 test accuracy={100 * test_acc_local_model:.2f}%") + val_acc_local_model = evaluate(model, valid_loader, DEVICE) + print(f"{site_name}: local validation accuracy={100 * val_acc_local_model:.2f}%") summary_writer.add_scalar( - tag="test_acc_local_model", - scalar=test_acc_local_model, + tag="val_acc_local_model", + scalar=val_acc_local_model, global_step=global_step, ) else: @@ -500,11 +496,11 @@ def main(args): ) if args.evaluate_local: - test_acc_local_model = evaluate(model, test_loader, DEVICE) - print(f"{site_name}: local CIFAR-10 test accuracy={100 * test_acc_local_model:.2f}%") + val_acc_local_model = evaluate(model, valid_loader, DEVICE) + print(f"{site_name}: local validation accuracy={100 * val_acc_local_model:.2f}%") summary_writer.add_scalar( - tag="test_acc_local_model", - scalar=test_acc_local_model, + tag="val_acc_local_model", + scalar=val_acc_local_model, global_step=global_epoch, ) diff --git a/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml b/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml index fa602b88e0..76543395bd 100644 --- a/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml +++ b/research/auto-fl-research/tasks/cifar10/mutation_schema.yaml @@ -1,12 +1,6 @@ target: task-local client.py, job.py, model.py; shared custom_aggregators.py goal: mutate local client behavior and bounded registered model architectures without changing the federated protocol; scaffold metadata is allowed only when explicitly selected -objective: - requested_metric: accuracy - optimization_metric: test_accuracy - metric_extraction_order: [test_accuracy, accuracy] - metric_source: held-out global CIFAR-10 test set via torchvision.datasets.CIFAR10(train=False) - fixed_invariants: api_loop: - flare.init() @@ -23,7 +17,6 @@ fixed_invariants: fixed_assets: - create_datasets/create_data_loaders contract - train_idx_root partitioning scheme - - global CIFAR-10 test-set evaluation via torchvision.datasets.CIFAR10(train=False) mutable_args: model_arch: @@ -66,7 +59,6 @@ comparison_budget_args: aggregator: weighted cross_site_eval: true final_eval_clients: site-1 - evaluation_split: cifar10_test run_timeout_seconds: 1200 fixed_within_campaign: - n_clients @@ -79,16 +71,13 @@ comparison_budget_args: - max_model_params - cross_site_eval - final_eval_clients - - evaluation_split note: > Keep communication and data-budget fields fixed within a comparison campaign. aggregation_epochs and local_train_steps are local-compute knobs: local_train_steps=0 means epoch-based training with aggregation_epochs, and local_train_steps>0 means exact optimizer steps per client per round. Vary only one local-compute mode in a narrow sweep and keep candidates - within run_timeout_seconds. The comparable CIFAR-10 score is held-out - test_accuracy from the global CIFAR-10 test set; accuracy is retained only - as a backward-compatible alias. + within run_timeout_seconds. split_cache: > CIFAR-10 train index splits are cached under /tmp/cifar10_splits by n_clients, alpha, and seed. Candidate job names must not create new splits diff --git a/research/auto-fl-research/tasks/cifar10/profile.md b/research/auto-fl-research/tasks/cifar10/profile.md index d9aae83685..caa30ee635 100644 --- a/research/auto-fl-research/tasks/cifar10/profile.md +++ b/research/auto-fl-research/tasks/cifar10/profile.md @@ -44,7 +44,6 @@ Each experiment should run under a **fixed communication, data, and evaluation b - `max_model_params` - whether cross-site evaluation is enabled - `final_eval_clients` -- the evaluation split (`torchvision.datasets.CIFAR10(train=False)`, the held-out CIFAR-10 test set) Some of these values are technically mutable in `mutation_schema.yaml`, but changing them starts a new comparison budget. Do not compare scores across runs with different values for the fixed budget fields above unless the run is explicitly labeled as a new campaign or subcampaign. Architecture-search scores must be labeled with their `model_arch` and `max_model_params`; do not mix them with optimizer-only `moderate_cnn` results as if they were the same search. @@ -62,13 +61,13 @@ Default H100 candidate budget: - `--max_model_params 5000000` - `--aggregator weighted` - cross-site evaluation enabled -- final global CIFAR-10 test-set evaluation on `site-1` +- final global evaluation on `site-1` - `--eval_batch_size 1024` - `RUN_TIMEOUT_SECONDS=1200` - deterministic PyTorch/DataLoader training enabled Each candidate targets a capped run on one local H100. The 80 GB H100 can usually support several same-budget candidates concurrently; if runs consistently finish much sooner, sweep local compute first with either `aggregation_epochs` or `local_train_steps`. If they time out or hit CUDA OOM, reduce candidate parallelism before changing communication, model, parameter-cap, or data contracts. -The CIFAR-10 evaluation loader is the held-out global CIFAR-10 test set (`torchvision.datasets.CIFAR10(train=False)`) and is identical on every simulated client. Final scoring evaluates the server/global model on `site-1` by default, reports both `test_accuracy` and the backward-compatible `accuracy` alias, and keeps the output in NVFlare's `cross_site_val/cross_val_results.json` path. Use `--final_eval_clients all` only for an audit run or after changing evaluation to be site-specific. +The current CIFAR-10 validation loader is identical on every simulated client, so final scoring evaluates the server/global model on `site-1` by default and keeps the output in NVFlare's `cross_site_val/cross_val_results.json` path. Use `--final_eval_clients all` only for an audit run or after changing validation to be site-specific. Training splits are cached by fixed data-budget fields under `/tmp/cifar10_splits/autofl_cifar10_sites_alpha_seed`. Do not make the split path depend on the candidate `--name`; repeated candidates with the same `n_clients`, `alpha`, and `seed` should reuse the same `.npy` indices. Client training derives stable per-site RNG seeds from `--seed`, enables PyTorch deterministic algorithms, disables cuDNN benchmarking, and seeds DataLoader shuffling/workers. Treat `--no_deterministic_training` as a separate noisy subcampaign. @@ -178,18 +177,6 @@ After finalizing reviewed statuses, run the plateau watchdog before selecting an If it prints `recommendation=literature`, stop local hyperparameter jittering, run the literature loop, record a `literature` row, and launch the selected source-backed candidates next. If it prints `recommendation=continue`, do not start a literature review just because one or two small batches missed; choose a clearer local sweep axis, narrow around near-misses, or inspect `mutation_schema.yaml` for another allowed axis. -Then refresh deterministic campaign state: - -```bash -"${PYTHON}" scripts/campaign_guard.py results.tsv --state .autoresearch/campaign_state.json --format json -``` - -If the guard returns `final_response_allowed=false`, do not produce a final -answer. Execute its `next_action`: finalize pending rows, run the literature -loop, or launch the next same-budget batch. Encoded defaults and refreshed -reports are checkpoints, not stop conditions, unless the guard allows a final -response. - When finalizing a same-H100 batch, use the CIFAR default width: ```bash diff --git a/skills/nvflare-autofl/README.md b/skills/nvflare-autofl/README.md deleted file mode 100644 index 9e2a8bfbef..0000000000 --- a/skills/nvflare-autofl/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# NVFLARE Auto-FL Skill - -This skill is intended to optimize any existing NVFLARE `job.py`. It is not -tied to the `research/auto-fl-research` bundle. - -## H100 Skill Trial Launcher - -For repeatable H100 validation, use -`scripts/launch_h100_skill_trial.sh`. The launcher prepares the environment -before the fresh coding agent starts: it clones the requested NVFLARE branch, -creates a Python 3.12 venv under the trial output directory, installs -job-local requirements when present, removes released `nvflare` packages, -installs the cloned repo editable, installs this skill into an isolated -`CODEX_HOME`, and starts Codex in `tmux` from the selected job directory. - -Default lightweight fixture: - -```bash -cd /scratch/hroth/Code/nvflare/ -AUTOFL_H100_BRANCH=codex/autofl-skill-v1 \ - skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh -``` - -That defaults to: - -```text -AUTOFL_H100_JOB=examples/hello-world/hello-pt/job.py -Prompt: Optimize ./job.py for accuracy in sim. -``` - -Run the bounded 10-candidate product UX smoke test: - -```bash -AUTOFL_H100_BRANCH= \ -AUTOFL_H100_JOB=examples/hello-world/hello-pt/job.py \ -AUTOFL_H100_PROMPT="Optimize ./job.py for accuracy in sim with a 10-candidate budget." \ -AUTOFL_H100_KILL_OLD=1 \ - skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh -``` - -Run the uncapped product UX trial; this should continue until you interrupt the -tmux session or stop the runner: - -```bash -AUTOFL_H100_BRANCH= \ -AUTOFL_H100_JOB=examples/hello-world/hello-pt/job.py \ -AUTOFL_H100_PROMPT="Optimize ./job.py for accuracy in sim." \ -AUTOFL_H100_KILL_OLD=1 \ - skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh -``` - -Run the same skill trial on any job in the cloned branch: - -```bash -AUTOFL_H100_BRANCH= \ -AUTOFL_H100_JOB=examples/advanced/sklearn-linear/job.py \ -AUTOFL_H100_REQUIREMENTS=auto \ - skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh -``` - -Run the heavier CIFAR research-style fixture only when that is the explicit -test target: - -```bash -AUTOFL_H100_BRANCH= \ -AUTOFL_H100_JOB=research/auto-fl-research/tasks/cifar10/job.py \ -AUTOFL_H100_PROMPT="Optimize ./job.py for accuracy in sim." \ - skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh -``` - -When a task-local `mutation_schema.yaml` is present, the deterministic runner -uses its comparison budget automatically. For the CIFAR-10 fixture this means -the H100 profile runs real CIFAR-10 with 8 clients, 20 rounds, 4 local epochs, -cross-site final evaluation, and the profile's timeout rather than the tiny -hello-pt synthetic smoke budget. - -Useful overrides: - -```bash -AUTOFL_H100_REPO_URL=git@github.com:/NVFlare.git -AUTOFL_H100_JOB=/absolute/path/to/job.py -AUTOFL_H100_JOB_CWD=/absolute/path/to/job-dir -AUTOFL_H100_REQUIREMENTS=/absolute/path/to/requirements.txt -AUTOFL_H100_REQUIREMENTS=none -AUTOFL_H100_NVFLARE_EXTRA=PT -AUTOFL_H100_PROMPT="Optimize ./job.py for AUC in prod with a 10-candidate budget." -AUTOFL_H100_KILL_OLD=1 -AUTOFL_H100_BOOTSTRAP_PYTHON=/path/to/python3.12 -``` - -Monitor without interrupting the agent: - -```bash -source "$(ls -td /scratch/hroth/Code/nvflare/pr4780-autofl-output/skill_trial_*/session.env | head -1)" -tmux capture-pane -pt "$SESSION" -S -120 -tail -f "$OUT/codex-tui.log" -``` diff --git a/skills/nvflare-autofl/SKILL.md b/skills/nvflare-autofl/SKILL.md index 32401eef5b..f5ad66705b 100644 --- a/skills/nvflare-autofl/SKILL.md +++ b/skills/nvflare-autofl/SKILL.md @@ -57,18 +57,14 @@ checkpoint status as an observation, then continue monitoring or executing the same runner while `final_response_allowed=false`. If the job directory contains a task-local `mutation_schema.yaml`, treat its -comparison budget and mutation bounds as authoritative. Invalid generated -proposals are product friction, not campaign blockers; preserve the same -campaign and continue with another same-budget candidate. - -Do not read or follow research harness program prose, task profile runbooks, -`scripts/init_run.sh`, `.autoresearch` branch rules, or manual -research campaign instructions before starting the product runner. Those files -belong to the incubator workflow and can pull the agent back into an old -agent-driven loop. The product runner may read `mutation_schema.yaml`, -`autofl.yaml`, and the original `job.py`; use broader research instructions only -if the runner is unavailable or the user explicitly asks for the legacy research -campaign. +`comparison_budget_args.default_candidate_budget` and mutation bounds as +authoritative. Invalid generated proposals are product friction, not campaign +blockers; preserve the same campaign and continue with another same-budget +candidate. + +Treat `job.py`, generated `autofl.yaml`, and optional job-local +`mutation_schema.yaml` as campaign inputs. Do not require example-specific +runbooks, branches, or initialization scripts. Request escalated execution for the runner command because NVFLARE simulator runs create local sockets that fail inside the restricted Codex sandbox. If a @@ -114,8 +110,8 @@ specific fields before running candidates. user explicitly asks you to prepare the environment. - Treat generated `autofl.yaml`, task-local `mutation_schema.yaml`, and existing NVFLARE job/runtime configuration as authoritative. In the default - simulation product flow, do not require task-local prose profiles, campaign - branch setup, or research harness initialization before invoking the runner. + simulation product flow, do not require task-local prose profiles, special + branch setup, or harness initialization before invoking the runner. - Use NVFLARE's existing execution surfaces: - For simulation, run the imported job with its configured `SimEnv`. - For POC and production, use standard `nvflare job submit`, `job wait`, @@ -146,13 +142,12 @@ source-code mutations that the runner cannot express yet. comparable candidate batch unless the code-owned state says `final_response_allowed=true` or production policy blocks execution. -## Autoresearch Operating Rule +## Continuous Campaign Rule -For uncapped campaigns, behave like the original Auto-FL research loop: after -setup and baseline, continue launching same-budget candidate batches until -manually interrupted. Do not ask whether to keep going. Do not produce a final -answer from your own judgment while the code-owned campaign state says -`final_response_allowed=false`. +For uncapped campaigns, continue launching same-budget candidate batches after +setup and baseline until manually interrupted. Do not ask whether to keep going. +Do not produce a final answer from your own judgment while the code-owned +campaign state says `final_response_allowed=false`. A kept improvement, refreshed plot, updated report, local commit, first plateau check, or encoded `job.py` default is a checkpoint, not completion. Treat the @@ -160,7 +155,7 @@ campaign state as authoritative: if `final_response_allowed=false`, execute `next_action` and keep the same `job.py`, `autofl.yaml`, metric, environment, ledger, and comparison budget. Use [continuous-campaigns.md](references/continuous-campaigns.md) for simulator -watchdogs, legacy `.autoresearch` guard handling, and recovery rules. +watchdogs, campaign-state handling, and recovery rules. ## Candidate Caps diff --git a/skills/nvflare-autofl/references/continuous-campaigns.md b/skills/nvflare-autofl/references/continuous-campaigns.md index fdcc6107e1..60983024de 100644 --- a/skills/nvflare-autofl/references/continuous-campaigns.md +++ b/skills/nvflare-autofl/references/continuous-campaigns.md @@ -28,11 +28,9 @@ GPU use and the child process remain active, keep waiting. ## Campaign Guards The product runner writes `.nvflare/autofl/campaign_state.json` through -`scripts/campaign_guard.py`; read that state before any final response. If a -legacy `.autoresearch/campaign_state.json` also exists, treat it as additional -research-harness context, but do not let it override the product runner state. -If the state has `final_response_allowed=false`, execute `next_action` -immediately; the skill text is only the interaction layer. +`scripts/campaign_guard.py`; read that state before any final response. This +product state is authoritative. If it has `final_response_allowed=false`, +execute `next_action` immediately; the skill text is only the interaction layer. Common next actions: diff --git a/skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh b/skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh deleted file mode 100755 index be47da5f8f..0000000000 --- a/skills/nvflare-autofl/scripts/launch_h100_skill_trial.sh +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Launch a fresh Codex Auto-FL skill UX trial on the H100 host. -# -# The launcher prepares NVFlare and job dependencies before the agent starts. -# The agent should test the product skill behavior on an existing job.py, not -# rediscover Python, package, or skill-install setup details. - -BASE=${AUTOFL_H100_BASE:-/scratch/hroth/Code/nvflare} -OUTPUT_BASE=${AUTOFL_H100_OUTPUT_BASE:-${BASE}/pr4780-autofl-output} -REPO_URL=${AUTOFL_H100_REPO_URL:-git@github.com:holgerroth/NVFlare.git} -BRANCH=${AUTOFL_H100_BRANCH:-codex/autofl-skill-v1} -JOB_PATH=${AUTOFL_H100_JOB:-examples/hello-world/hello-pt/job.py} -BOOTSTRAP_PYTHON=${AUTOFL_H100_BOOTSTRAP_PYTHON:-/scratch/hroth/Code/auto-fl/.venv312/bin/python} -SESSION_PREFIX=${AUTOFL_H100_SESSION_PREFIX:-pr4780-autofl-skill} -KILL_OLD=${AUTOFL_H100_KILL_OLD:-0} -SKIP_DEPS=${AUTOFL_H100_SKIP_DEPS:-0} -NVFLARE_EXTRA=${AUTOFL_H100_NVFLARE_EXTRA:-PT} -REQUIREMENTS=${AUTOFL_H100_REQUIREMENTS:-auto} -PARALLEL_CANDIDATES=${AUTOFL_H100_PARALLEL_CANDIDATES:-4} -CUDA_VISIBLE_DEVICES_VALUE=${AUTOFL_H100_CUDA_VISIBLE_DEVICES:-0} -OVERLAY_TGZ=${AUTOFL_H100_OVERLAY_TGZ:-} - -TS=${AUTOFL_H100_TS:-$(date +%Y%m%d_%H%M%S)} -REPO=${AUTOFL_H100_REPO:-${BASE}/pr4780-autofl-skill-trial-${TS}} -OUT=${AUTOFL_H100_OUT:-${OUTPUT_BASE}/skill_trial_${TS}} -CODEX_TRIAL=${AUTOFL_H100_CODEX_HOME:-${OUT}/codex_home} -VENV_DIR=${AUTOFL_H100_VENV:-${OUT}/venv} -SESSION=${AUTOFL_H100_SESSION:-${SESSION_PREFIX}-${TS}} - -find_bootstrap_python() { - if [[ -x "${BOOTSTRAP_PYTHON}" ]]; then - printf '%s\n' "${BOOTSTRAP_PYTHON}" - return - fi - if command -v python3.12 >/dev/null 2>&1; then - command -v python3.12 - return - fi - echo "ERROR: no Python 3.12 bootstrap interpreter found." >&2 - echo "Set AUTOFL_H100_BOOTSTRAP_PYTHON to a Python 3.12 executable." >&2 - exit 2 -} - -resolve_path() { - local base="$1" - local path="$2" - if [[ "${path}" = /* ]]; then - printf '%s\n' "${path}" - else - printf '%s\n' "${base}/${path}" - fi -} - -install_requirements_without_nvflare() { - local requirements_file="$1" - local filtered_requirements="${OUT}/requirements.filtered.txt" - - awk ' - /^[[:space:]]*($|#)/ { print; next } - /^[[:space:]]*nvflare([[:space:]\[<=>!~].*)?$/ { next } - { print } - ' "${requirements_file}" > "${filtered_requirements}" - - if grep -Eq '^[[:space:]]*[^#[:space:]]' "${filtered_requirements}"; then - "${PYTHON}" -m pip install -r "${filtered_requirements}" - fi -} - -if [[ "${KILL_OLD}" == "1" ]]; then - tmux ls 2>/dev/null | awk -F: -v prefix="${SESSION_PREFIX}" '$1 ~ "^" prefix {print $1}' | while read -r old_session; do - [[ -n "${old_session}" ]] && tmux kill-session -t "${old_session}" || true - done -fi - -mkdir -p "${BASE}" "${OUTPUT_BASE}" "${OUT}" - -if [[ -e "${REPO}" ]]; then - echo "ERROR: repo path already exists: ${REPO}" >&2 - exit 2 -fi - -git clone --branch "${BRANCH}" "${REPO_URL}" "${REPO}" - -if [[ -n "${OVERLAY_TGZ}" ]]; then - if [[ ! -f "${OVERLAY_TGZ}" ]]; then - echo "ERROR: overlay tarball not found: ${OVERLAY_TGZ}" >&2 - exit 2 - fi - tar -xzf "${OVERLAY_TGZ}" -C "${REPO}" -fi - -if [[ ! -d "${REPO}/skills/nvflare-autofl" ]]; then - echo "ERROR: bundled skill not found: ${REPO}/skills/nvflare-autofl" >&2 - exit 2 -fi - -JOB_ABS=$(resolve_path "${REPO}" "${JOB_PATH}") -if [[ ! -f "${JOB_ABS}" ]]; then - echo "ERROR: job.py not found: ${JOB_ABS}" >&2 - exit 2 -fi - -if [[ -n "${AUTOFL_H100_JOB_CWD:-}" ]]; then - JOB_CWD=$(resolve_path "${REPO}" "${AUTOFL_H100_JOB_CWD}") -else - JOB_CWD=$(dirname "${JOB_ABS}") -fi -if [[ ! -d "${JOB_CWD}" ]]; then - echo "ERROR: job cwd not found: ${JOB_CWD}" >&2 - exit 2 -fi - -PY_BOOTSTRAP=$(find_bootstrap_python) -PYTHON="${VENV_DIR}/bin/python" - -if [[ "${SKIP_DEPS}" != "1" ]]; then - "${PY_BOOTSTRAP}" -m venv "${VENV_DIR}" - "${PYTHON}" -m pip install --upgrade pip - - if [[ "${REQUIREMENTS}" == "auto" ]]; then - REQUIREMENTS="${JOB_CWD}/requirements.txt" - elif [[ "${REQUIREMENTS}" != "none" ]]; then - REQUIREMENTS=$(resolve_path "${REPO}" "${REQUIREMENTS}") - fi - - if [[ "${REQUIREMENTS}" != "none" && -f "${REQUIREMENTS}" ]]; then - install_requirements_without_nvflare "${REQUIREMENTS}" - fi - - "${PYTHON}" -m pip uninstall -y nvflare-nightly >/dev/null 2>&1 || true - "${PYTHON}" -m pip uninstall -y nvflare >/dev/null 2>&1 || true - if [[ -n "${NVFLARE_EXTRA}" ]]; then - "${PYTHON}" -m pip install -e "${REPO}[${NVFLARE_EXTRA}]" - else - "${PYTHON}" -m pip install -e "${REPO}" - fi -fi - -"${PYTHON}" -c 'import sys; assert sys.version_info[:2] == (3, 12), sys.version; print(sys.executable)' -"${PYTHON}" -c 'import msgpack, nvflare; print("nvflare", nvflare.__file__)' - -mkdir -p "${CODEX_TRIAL}/skills" -cp "${HOME}/.codex/config.toml" "${CODEX_TRIAL}/config.toml" -if [[ -f "${HOME}/.codex/auth.json" ]]; then - ln -sf "${HOME}/.codex/auth.json" "${CODEX_TRIAL}/auth.json" -fi -chmod 700 "${CODEX_TRIAL}" 2>/dev/null || true - -cp -R "${REPO}/skills/nvflare-autofl" "${CODEX_TRIAL}/skills/nvflare-autofl" - -cat >> "${CODEX_TRIAL}/config.toml" < "${OUT}/initial_prompt.txt" < "${OUT}/session.env" <> '${OUT}/codex-tui.log'" - -cat < Dict[str, Any]: return yaml.safe_load(path.read_text(encoding="utf-8")) or {} -def h100_comparison_budget(schema: Dict[str, Any]) -> Dict[str, Any]: - return ( - schema.get("comparison_budget_args", {}).get("h100_default_candidate_budget", {}) - if isinstance(schema.get("comparison_budget_args"), dict) - else {} - ) +def comparison_budget(schema: Dict[str, Any]) -> Dict[str, Any]: + comparison = schema.get("comparison_budget_args") + if not isinstance(comparison, dict): + return {} + budget = comparison.get("default_candidate_budget") + return budget if isinstance(budget, dict) else {} def fixed_within_campaign(schema: Dict[str, Any]) -> set: @@ -782,10 +781,10 @@ def fixed_within_campaign(schema: Dict[str, Any]) -> set: return set(values) if isinstance(values, list) else set() -def build_profile_args(schema: Dict[str, Any], help_text: str) -> List[str]: - budget = h100_comparison_budget(schema) +def build_comparison_budget_args(schema: Dict[str, Any], help_text: str) -> List[str]: + budget = comparison_budget(schema) args: List[str] = [] - for field, cli_name in PROFILE_BUDGET_TO_CLI.items(): + for field, cli_name in COMPARISON_BUDGET_TO_CLI.items(): value = budget.get(field) if value is not None and supports_flag(help_text, f"--{cli_name}"): args.extend([f"--{cli_name}", str(value)]) @@ -796,13 +795,13 @@ def build_profile_args(schema: Dict[str, Any], help_text: str) -> List[str]: def build_fixed_args(config: Dict[str, Any], help_text: str, schema: Dict[str, Any]) -> List[str]: fixed = config.get("budget", {}).get("fixed_training_budget", {}) or {} - profile_budget = h100_comparison_budget(schema) - profile_cli_names = { - cli_name for field, cli_name in PROFILE_BUDGET_TO_CLI.items() if profile_budget.get(field) is not None + budget = comparison_budget(schema) + budget_cli_names = { + cli_name for field, cli_name in COMPARISON_BUDGET_TO_CLI.items() if budget.get(field) is not None } args: List[str] = [] for field, cli_name in FIXED_BUDGET_TO_CLI.items(): - if cli_name in profile_cli_names: + if cli_name in budget_cli_names: continue value = fixed.get(field) if value is not None and supports_flag(help_text, f"--{cli_name}"): @@ -812,9 +811,9 @@ def build_fixed_args(config: Dict[str, Any], help_text: str, schema: Dict[str, A def build_base_args(args: argparse.Namespace, help_text: str, schema: Dict[str, Any]) -> List[str]: base = shlex.split(args.base_args) - profile_args = build_profile_args(schema, help_text) - if profile_args: - base.extend(profile_args) + budget_args = build_comparison_budget_args(schema, help_text) + if budget_args: + base.extend(budget_args) if args.prefer_synthetic and supports_flag(help_text, "--synthetic_data"): if "--synthetic_data" not in base: base.append("--synthetic_data") @@ -1370,13 +1369,13 @@ def main(argv: Optional[Sequence[str]] = None) -> int: stop_files = resolve_stop_files(cwd, args.stop_file) output_root.mkdir(parents=True, exist_ok=True) schema = load_mutation_schema(cwd) - profile_budget = h100_comparison_budget(schema) - profile_timeout = profile_budget.get("run_timeout_seconds") - timeout = max(args.timeout, int(profile_timeout)) if profile_timeout is not None else args.timeout - profile_no_progress_timeout = profile_budget.get("simulator_no_progress_timeout_seconds") + budget = comparison_budget(schema) + budget_timeout = budget.get("run_timeout_seconds") + timeout = max(args.timeout, int(budget_timeout)) if budget_timeout is not None else args.timeout + budget_no_progress_timeout = budget.get("simulator_no_progress_timeout_seconds") simulator_no_progress_timeout = ( - int(profile_no_progress_timeout) - if profile_no_progress_timeout is not None + int(budget_no_progress_timeout) + if budget_no_progress_timeout is not None else args.simulator_no_progress_timeout ) diff --git a/skills/nvflare-autofl/tests/helper_scripts.md b/skills/nvflare-autofl/tests/helper_scripts.md index eab2e7fb5b..f7a33e6e37 100644 --- a/skills/nvflare-autofl/tests/helper_scripts.md +++ b/skills/nvflare-autofl/tests/helper_scripts.md @@ -1,8 +1,8 @@ # Helper Script Coverage -Repository tests cover the bundled Python campaign runner in -`tests/unit_test/tool/autofl_skill_runner_test.py`. +The bundled campaign helpers are covered by focused repository tests: -The H100 launcher is a host-side integration helper for fresh-agent trials. It -is exercised during H100 validation and kept under the skill so reviewers can -inspect the exact setup command used for end-to-end UX tests. +- `scripts/campaign_guard.py`: + `tests/unit_test/tool/autofl_skill_campaign_guard_test.py` +- `scripts/run_job_campaign.py`: + `tests/unit_test/tool/autofl_skill_runner_test.py` diff --git a/tests/unit_test/research/__init__.py b/tests/unit_test/research/__init__.py deleted file mode 100644 index 4fc25d0d3c..0000000000 --- a/tests/unit_test/research/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/tests/unit_test/research/autofl_campaign_guard_test.py b/tests/unit_test/research/autofl_campaign_guard_test.py deleted file mode 100644 index cc200d3d8b..0000000000 --- a/tests/unit_test/research/autofl_campaign_guard_test.py +++ /dev/null @@ -1,129 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import os -import subprocess -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[3] -GUARD = REPO_ROOT / "research" / "auto-fl-research" / "scripts" / "campaign_guard.py" -HEADER = "commit\tscore\truntime_seconds\tbudget\tstatus\ttarget\tdescription\tartifacts\n" - - -def _write_results(path, rows): - path.write_text(HEADER + "\n".join(rows) + "\n", encoding="utf-8") - - -def _run_guard(tmp_path, *args, env=None): - state_path = tmp_path / "campaign_state.json" - command = [ - sys.executable, - str(GUARD), - str(tmp_path / "results.tsv"), - "--state", - str(state_path), - "--format", - "json", - *args, - ] - process_env = None if env is None else {**os.environ, **env} - process = subprocess.run(command, cwd=tmp_path, text=True, capture_output=True, check=True, env=process_env) - payload = json.loads(process.stdout) - assert json.loads(state_path.read_text(encoding="utf-8")) == payload - return payload - - -def test_campaign_guard_continues_uncapped_after_verified_default(tmp_path): - _write_results( - tmp_path / "results.tsv", - [ - "abc\t0.84\t10\t--name baseline\tkeep\tjob.py\tbaseline\t/tmp/baseline", - "def\t0.87\t20\t--name encoded\tkeep\tjob.py\tencoded defaults verified\t/tmp/encoded", - ], - ) - - payload = _run_guard(tmp_path) - - assert payload["decision"] == "continue" - assert payload["next_action"] == "launch_next_candidate_batch" - assert payload["final_response_allowed"] is False - assert "Do not produce a final answer" in payload["agent_instruction"] - - -def test_campaign_guard_requires_finalizing_pending_candidates(tmp_path): - _write_results( - tmp_path / "results.tsv", - [ - "abc\t0.84\t10\t--name baseline\tkeep\tjob.py\tbaseline\t/tmp/baseline", - "def\t0.86\t20\t--name candidate\tcandidate\tjob.py\tcandidate row\t/tmp/candidate", - ], - ) - - payload = _run_guard(tmp_path) - - assert payload["decision"] == "continue" - assert payload["reason"] == "pending_candidates" - assert payload["next_action"] == "finalize_pending_candidates" - assert payload["final_response_allowed"] is False - - -def test_campaign_guard_allows_final_report_after_explicit_cap(tmp_path): - _write_results( - tmp_path / "results.tsv", - [ - "abc\t0.84\t10\t--name baseline\tkeep\tjob.py\tbaseline\t/tmp/baseline", - "def\t0.86\t20\t--name candidate\tdiscard\tjob.py\tcandidate row\t/tmp/candidate", - ], - ) - - payload = _run_guard(tmp_path, "--max-candidates", "1") - - assert payload["decision"] == "stop" - assert payload["reason"] == "candidate_cap_exhausted" - assert payload["next_action"] == "final_report" - assert payload["final_response_allowed"] is True - - -def test_campaign_guard_ignores_ambient_env_cap(tmp_path): - _write_results( - tmp_path / "results.tsv", - [ - "abc\t0.84\t10\t--name baseline\tkeep\tjob.py\tbaseline\t/tmp/baseline", - "def\t0.86\t20\t--name candidate\tdiscard\tjob.py\tcandidate row\t/tmp/candidate", - ], - ) - - payload = _run_guard(tmp_path, env={"AUTOFL_MAX_CANDIDATES": "1"}) - - assert payload["decision"] == "continue" - assert payload["reason"] == "continue" - assert payload["candidate_cap"] is None - assert payload["candidate_cap_source"] == "uncapped" - assert payload["final_response_allowed"] is False - - -def test_campaign_guard_reports_best_score_for_minimization(tmp_path): - _write_results( - tmp_path / "results.tsv", - [ - "abc\t0.84\t10\t--name baseline\tkeep\tjob.py\tbaseline\t/tmp/baseline", - "def\t0.42\t20\t--name candidate\tkeep\tjob.py\tcandidate row\t/tmp/candidate", - ], - ) - - payload = _run_guard(tmp_path, "--mode", "min") - - assert payload["best_score"] == 0.42 diff --git a/tests/unit_test/research/autofl_extract_score_test.py b/tests/unit_test/research/autofl_extract_score_test.py deleted file mode 100644 index 8bb5d97e87..0000000000 --- a/tests/unit_test/research/autofl_extract_score_test.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import importlib.util -import sys -from pathlib import Path - - -def _load_extract_score(): - repo_root = Path(__file__).parents[3] - script_path = repo_root / "research" / "auto-fl-research" / "scripts" / "extract_score.py" - spec = importlib.util.spec_from_file_location("nvflare_autofl_extract_score", script_path) - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -def test_extract_score_prefers_explicit_test_accuracy(): - extract_score = _load_extract_score() - data = { - "site-1": { - extract_score.PRIMARY_MODEL_KEY: { - "accuracy": 0.5, - "test_accuracy": 0.8, - } - } - } - - assert extract_score.extract_score(data) == 0.8 diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index 99d1f92211..4cbb6f258d 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -101,12 +101,12 @@ def test_runner_applies_schema_metric_contract(): assert updated["objective"]["metric_source"] == "held-out CIFAR-10 test set" -def test_profile_budget_suppresses_duplicate_imported_fixed_budget_args(): +def test_comparison_budget_suppresses_duplicate_imported_fixed_budget_args(): runner = _load_runner() config = {"budget": {"fixed_training_budget": {"num_clients": 8, "num_rounds": 10}}} schema = { "comparison_budget_args": { - "h100_default_candidate_budget": { + "default_candidate_budget": { "n_clients": 8, "num_rounds": 20, } From 3012a2fe72162c0484ba9c890c1601114ecfcbff Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Mon, 29 Jun 2026 12:13:47 -0400 Subject: [PATCH 16/23] Make Auto-FL code candidates first-class Signed-off-by: Holger Roth --- docs/design/autofl_skill.md | 42 +- docs/user_guide/nvflare_cli/autofl_skill.rst | 44 +- nvflare/app_common/autofl/job_importer.py | 3 + skills/nvflare-autofl/BENCHMARK.md | 2 +- skills/nvflare-autofl/SKILL.md | 99 +- skills/nvflare-autofl/evals/evals.json | 20 +- .../references/continuous-campaigns.md | 41 +- .../nvflare-autofl/scripts/campaign_guard.py | 4 +- .../scripts/run_job_campaign.py | 1299 ++++++++++++++--- .../app_common/autofl/job_importer_test.py | 2 + .../tool/autofl_skill_campaign_guard_test.py | 10 +- .../tool/autofl_skill_runner_test.py | 386 +++++ 12 files changed, 1664 insertions(+), 288 deletions(-) diff --git a/docs/design/autofl_skill.md b/docs/design/autofl_skill.md index c0ef3f5bfb..46d15b4b90 100644 --- a/docs/design/autofl_skill.md +++ b/docs/design/autofl_skill.md @@ -23,6 +23,9 @@ The first production-oriented slice includes: Auto-FL campaign. - A trust contract in `autofl.yaml` showing editable campaign settings, unresolved fields, fixed-budget constraints, and allowed edit paths. +- A skill-local candidate lifecycle that snapshots the current best source, + gives the agent an isolated draft, validates the resulting patch, and keeps or + restores source according to the campaign metric. - Documentation for using the skill with simulation, POC, and production environments through existing NVFlare surfaces. @@ -44,6 +47,7 @@ layer: scripts. - Fixed-budget constraints that must remain comparable across candidates. - Allowed edit paths and files that are out of scope for the agent. +- Allowed creation patterns for new Python modules under the job root. - Artifact, ledger, and report locations for the campaign. - Provenance and unresolved fields that need user review before safe execution. @@ -92,15 +96,40 @@ The skill must present editable, unresolved, and allowed sections before it runs candidates. This is the core product guardrail: NVFlare makes the campaign reviewable and reproducible; the agent makes it interactive and exploratory. +## Candidate Contract + +The agent, rather than the deterministic runner, owns search policy. It may +change tunables, edit the imported job's allowed source files, or implement new +algorithms as Python modules. Each attempt starts from the retained best source +in `.nvflare/autofl/candidates//source` and has a generated +`candidate_manifest.json` containing its hypothesis, base candidate, run +arguments, changed files, source and budget hashes, patch hash, artifacts, and +result. + +NVFlare computes the manifest's evidence fields; the agent does not assert them. +Before execution, the helper rejects stale candidates, path traversal, symlink +escapes, unauthorized existing-file edits, and detectable fixed-budget drift. +It applies the candidate transactionally to the real job workspace, retains a +new best, and restores the previous best after a discard or crash. This works +without requiring a Git repository and leaves the best source ready for the +standard NVFlare job lifecycle. + +The built-in parameter candidates are suggestion seeds only. They are returned +as machine-readable hypotheses and arguments when requested, but are not the +default search loop and are never executed without agent selection. + ## Execution Model The skill uses existing NVFlare execution surfaces: -- Simulation: run the existing `job.py` with its configured `SimEnv`. +- Simulation: initialize a baseline, prepare an agent-authored candidate draft, + and evaluate it through the existing `job.py` and configured `SimEnv`. - POC: use the existing job authoring/export flow, startup kits, and standard - `nvflare job` commands. + `nvflare job` commands, then record the job ID, artifacts, and metric against + the candidate manifest. - Production: use standard startup-kit authentication, site policy, job submit, - wait, download, and inspection commands. + wait, download, and inspection commands with the same manifest and result + recording contract. Production is a valid optimization environment. The best candidate may later be submitted or reused through the standard NVFlare job lifecycle; no separate @@ -109,10 +138,11 @@ promotion command is needed. ## Review Questions - Are the supported `job.py` patterns sufficient for an initial prototype? -- Are the editable `autofl.yaml` fields enough for human-in-the-loop campaign - review and candidate comparability? +- Are the edit and creation permissions in `autofl.yaml` appropriate for + algorithm-level candidates while preserving candidate comparability? - Which exported-job fields should be used as validation evidence versus static `job.py` parsing for authoring intent? - Does the Auto-FL skill pass the general NVFLARE skill frontmatter, trigger, and eval checks after it lands under `skills/nvflare-autofl`? -- Which metric/artifact extraction gaps should become stable NVFlare APIs next? +- Which candidate-manifest and metric/artifact fields should become stable + NVFlare APIs after the skill-local contract proves itself? diff --git a/docs/user_guide/nvflare_cli/autofl_skill.rst b/docs/user_guide/nvflare_cli/autofl_skill.rst index 2fd43439b1..b3a4ee6d5a 100644 --- a/docs/user_guide/nvflare_cli/autofl_skill.rst +++ b/docs/user_guide/nvflare_cli/autofl_skill.rst @@ -16,8 +16,8 @@ the standard ``nvflare agent skills`` workflow for the target coding agent. NVFlare does not add a separate public Auto-FL command family for this workflow. Instead, NVFlare provides the deterministic import, reviewable ``autofl.yaml`` contract, execution substrate, policy boundaries, artifacts, -and reproducibility evidence. The agent plans candidate edits and runs them -through existing NVFlare surfaces. +and reproducibility evidence. The agent chooses hypotheses, edits source, +implements algorithms, and runs candidates through existing NVFlare surfaces. ``autofl.yaml`` is the human-reviewable campaign configuration, not a replacement for ``job.py`` or for exported NVFlare job folders. It exposes the editable @@ -62,7 +62,8 @@ things from ``autofl.yaml``: - **Unresolved**: dynamic defaults, unsupported Python semantics, missing metric sources, unknown data paths, or low-confidence fields. - **Allowed**: files the agent may edit, fixed-budget fields it must preserve, - and environment or policy boundaries. + Python modules it may add under the job root, and environment or policy + boundaries. This makes the workflow feel native and reproducible: NVFlare owns the truth of the campaign settings and execution surfaces; the agent owns exploration within @@ -71,11 +72,44 @@ explicit constraints. Execution ========= -The skill uses existing NVFlare surfaces after import: +The bundled helper is an internal skill surface, not a public NVFlare command +family. It first initializes the campaign and baseline: + +.. code-block:: shell + + python "$CODEX_HOME/skills/nvflare-autofl/scripts/run_job_campaign.py" \ + initialize ./job.py --metric accuracy --mode max --env sim + +For each attempt, the agent supplies a hypothesis and receives an isolated +candidate source directory plus ``candidate_manifest.json``: + +.. code-block:: shell + + python "$CODEX_HOME/skills/nvflare-autofl/scripts/run_job_campaign.py" \ + prepare ./job.py --name fedprox-variant \ + --hypothesis "stabilize heterogeneous client updates" + +The agent edits that candidate source, including new Python algorithm modules +when useful, and asks the helper to evaluate it: + +.. code-block:: shell + + python "$CODEX_HOME/skills/nvflare-autofl/scripts/run_job_campaign.py" \ + evaluate ./job.py --manifest + +NVFlare computes the source diff and hash, checks allowed paths and detectable +fixed-budget drift, executes the candidate, updates ``results.tsv`` and +``progress.png``, and either retains the new best source or restores the prior +best. Built-in tunable candidates are available through the helper's +``suggest`` action only as optional seeds; the agent remains free to implement +new algorithms. + +The workflow then uses existing NVFlare execution surfaces: - Simulation jobs run through the job's configured ``SimEnv``. - POC and production jobs use the standard startup-kit and ``nvflare job`` - submission, wait, download, and inspection commands. + submission, wait, download, and inspection commands. The skill records the + resulting job ID, artifacts, and metric against the candidate manifest. - Production execution is allowed when the user requests it, but the skill must not bypass normal startup-kit authentication, site policy, or job submission. diff --git a/nvflare/app_common/autofl/job_importer.py b/nvflare/app_common/autofl/job_importer.py index 13b911ab14..343f495911 100644 --- a/nvflare/app_common/autofl/job_importer.py +++ b/nvflare/app_common/autofl/job_importer.py @@ -34,6 +34,7 @@ AUTOFL_CONFIG_SCHEMA_VERSION = "nvflare.autofl.config.v1" IMPORTER_VERSION = "nvflare-autofl-job-importer/v1" +ALLOWED_CREATE_PATTERNS = ["**/*.py"] SUPPORTED_ENV_NAMES = {"PocEnv", "ProdEnv", "SimEnv"} TUNABLE_ARG_NAMES = { @@ -167,6 +168,7 @@ def import_job( "surface": _surface_name(job_call), "entrypoint": "main" if _has_main_entrypoint(tree) else "unresolved", "allowed_edit_paths": allowed_edit_paths, + "allowed_create_patterns": list(ALLOWED_CREATE_PATTERNS), } if job_call: call_args, call_issues = self._resolved_call_keywords(job_call, index.parser_args, source_text) @@ -228,6 +230,7 @@ def import_job( ), "unresolved": list(unresolved), "allowed_edit_paths": allowed_edit_paths, + "allowed_create_patterns": list(ALLOWED_CREATE_PATTERNS), "agent_controls": { "must_not_edit_outside_allowed_paths": True, "must_preserve_fixed_training_budget": bool(budget.get("fixed_training_budget")), diff --git a/skills/nvflare-autofl/BENCHMARK.md b/skills/nvflare-autofl/BENCHMARK.md index c5f22b52e2..e8e9174f69 100644 --- a/skills/nvflare-autofl/BENCHMARK.md +++ b/skills/nvflare-autofl/BENCHMARK.md @@ -13,7 +13,7 @@ FLARE version: 2.8.0 minimum | Adjacent negative trigger | Draft | PyTorch conversion routes to `nvflare-convert-pytorch`. | | Diagnosis negative trigger | Draft | Failed-job diagnosis routes to `nvflare-diagnose-job`. | | Global negative trigger | Draft | Non-FLARE prompts route to no skill. | -| Mandatory behavior | Draft | Behavior IDs cover deterministic import, campaign summary, bounded edits, and existing FLARE execution. | +| Mandatory behavior | Draft | Behavior IDs cover deterministic import, agent-authored code candidates, candidate manifests, bounded edits, and existing FLARE execution. | | Prohibited behavior | Draft | Behavior IDs prohibit bypassing policy, editing outside allowed paths, and treating `autofl.yaml` as an exported job. | | Process evaluation | Draft | Metrics cover import-first behavior, contract preservation, score extraction, and unwanted production actions. | diff --git a/skills/nvflare-autofl/SKILL.md b/skills/nvflare-autofl/SKILL.md index f5ad66705b..e471a11c45 100644 --- a/skills/nvflare-autofl/SKILL.md +++ b/skills/nvflare-autofl/SKILL.md @@ -26,35 +26,36 @@ Use this skill to optimize an existing NVFLARE `job.py` without asking the user to learn a new Auto-FL command tree. The user selects this skill, points to a job, and states the objective, environment, and optional budget. NVFLARE provides the deterministic campaign import, execution substrate, policy -boundaries, artifacts, and machine-readable contracts. For simulation, the -coding agent invokes the product runner and monitors its code-owned state; the -runner owns candidate generation, execution, comparison, plots, and reports. -Manual agent edits are a fallback path, not the default product experience. +boundaries, artifacts, and machine-readable contracts. The coding agent owns +hypotheses, source edits, new algorithm implementations, and candidate choice. -Before any candidate work, import the job deterministically: +Initialize the campaign and baseline through the bundled helper: ```bash -python -m nvflare.app_common.autofl.job_importer ./job.py --metric --env --max-candidates --output autofl.yaml +python "$CODEX_HOME/skills/nvflare-autofl/scripts/run_job_campaign.py" initialize ./job.py --metric --mode --env [--max-candidates ] ``` -If the user did not specify a candidate cap, omit `--max-candidates` or leave it -unset in the review summary. Do not invent a default cap. +Read `autofl.yaml` and the JSON response, then prepare an agent-authored +candidate with a short hypothesis and optional candidate-only arguments: -For simulation campaigns, use the bundled deterministic campaign runner as the -code-owned loop. Include -`--max-candidates ` only when the user gave an explicit candidate budget: +```bash +python "$CODEX_HOME/skills/nvflare-autofl/scripts/run_job_campaign.py" prepare ./job.py --name --hypothesis "" [--run-args ""] +``` + +Edit only the returned candidate source directory. Modify existing allowed +files or add Python modules under the job root; do not edit the live best source +directly. Then evaluate the manifest: ```bash -python "$CODEX_HOME/skills/nvflare-autofl/scripts/run_job_campaign.py" ./job.py --metric --env sim [--max-candidates ] +python "$CODEX_HOME/skills/nvflare-autofl/scripts/run_job_campaign.py" evaluate ./job.py --manifest ``` -If the user did not specify a candidate cap, omit `--max-candidates`; the -runner will keep launching same-budget candidate attempts and refresh -`results.tsv`, `progress.png`, `.nvflare/autofl/campaign_state.json`, and -the Auto-FL report after every finalized candidate until interrupted or blocked. -In this uncapped mode, do not ask the user whether to keep going. Report -checkpoint status as an observation, then continue monitoring or executing the -same runner while `final_response_allowed=false`. +Simulation evaluation runs the candidate immediately. POC and production +evaluation validates and materializes the candidate; submit it with standard +`nvflare job` commands, then call `record` with the manifest, job ID, artifacts, +and score. Use `abandon` to restore a pending candidate. Use `suggest` only when +you want deterministic tunable seeds; suggestions are never executed +automatically and do not limit agent-authored code candidates. If the job directory contains a task-local `mutation_schema.yaml`, treat its `comparison_budget_args.default_candidate_budget` and mutation bounds as @@ -72,13 +73,11 @@ runner command reports a sandbox/socket permission failure, treat it as an infrastructure retry, not as a candidate result, and rerun the same command with escalated execution. -The runner owns deterministic import, baseline/candidate execution, candidate -counting, ledger updates, campaign state, progress plotting, and the concise -report. Do not produce a final response while the runner is active. After it -exits, read `.nvflare/autofl/campaign_state.json` and only finalize when -`final_response_allowed=true`. If an uncapped runner exits for a recoverable -runner/schema/simulator issue, repair the cause and resume the same requested -optimization once. For long-running and simulator-stall handling, read +The helper owns deterministic import, source snapshots, candidate validation, +execution, restoration, counting, ledger updates, campaign state, plotting, +and reports. After each lifecycle action, read +`.nvflare/autofl/campaign_state.json` and only finalize when +`final_response_allowed=true`. For long-running and simulator-stall handling, read [continuous-campaigns.md](references/continuous-campaigns.md). Read `autofl.yaml` and show the user a concise campaign summary: @@ -87,8 +86,9 @@ Read `autofl.yaml` and show the user a concise campaign summary: locations, `objective.optimization_metric`, metric source, source hash, and importer version. - **Unresolved**: dynamic defaults, unsupported Python semantics, missing metric sources, unknown data paths, or any low-confidence fields. -- **Allowed**: files the agent may edit, fixed-budget fields it must preserve, - and policy boundaries for the requested environment. +- **Allowed**: files the agent may edit, Python source it may create, + fixed-budget fields it must preserve, and policy boundaries for the requested + environment. Treat `autofl.yaml` as the human-reviewable Auto-FL campaign config, not as a replacement for `job.py` or an exported NVFLARE job folder. Use the original @@ -100,7 +100,9 @@ specific fields before running candidates. ## Requirements -- Do not edit outside `job.allowed_edit_paths`. +- Edit existing files only through candidate drafts and within + `job.allowed_edit_paths`. New Python modules may match + `job.allowed_create_patterns` under the job root. - Preserve `budget.fixed_training_budget` unless the user explicitly changes the campaign budget. - If the environment provides `PYTHON`, `VIRTUAL_ENV`, or a venv on `PATH`, @@ -125,29 +127,20 @@ specific fields before running candidates. ## Candidate Loop -For simulation (`--env sim`), prefer `scripts/run_job_campaign.py` for both -explicitly capped and uncapped campaigns. Start the runner before inspecting or -editing task code beyond the deterministic import and `mutation_schema.yaml` -review. Use the manual loop below only when the runner is unavailable, when the -requested environment is POC/production, or when the user explicitly asks for -source-code mutations that the runner cannot express yet. - -1. Inspect `autofl.yaml`, the allowed files, and the current job behavior. -2. Propose and run a candidate tied to supported tunables or allowed files. -3. Validate importability and fixed-budget comparability. -4. Extract the requested metric from NVFLARE artifacts/logs. -5. Update `results.tsv`: non-survivors=`discard`, crashes=`crash`, - survivor=`keep`, unresolved=`candidate`; prefer explicit metrics such as `test_accuracy`. -6. Refresh `progress.png`, update campaign state, and launch the next - comparable candidate batch unless the code-owned state says - `final_response_allowed=true` or production policy blocks execution. +1. Inspect `autofl.yaml`, current best source, prior manifests, and results. +2. Form a concrete hypothesis. Use literature, framework knowledge, source + edits, new algorithms, or a fallback tunable suggestion as appropriate. +3. Prepare a candidate, edit its draft, and evaluate its manifest. +4. Let the helper validate paths and fixed-budget comparability, compute the + patch hash, execute or materialize it, extract metrics, and keep or restore. +5. Read campaign state and execute `next_action`. Run a source-backed literature + pass when requested, then implement its strongest compatible idea. ## Continuous Campaign Rule -For uncapped campaigns, continue launching same-budget candidate batches after -setup and baseline until manually interrupted. Do not ask whether to keep going. -Do not produce a final answer from your own judgment while the code-owned -campaign state says `final_response_allowed=false`. +For uncapped campaigns, continue proposing and evaluating same-budget candidates +after setup and baseline until manually interrupted. Do not ask whether to keep +going or finalize while campaign state says `final_response_allowed=false`. A kept improvement, refreshed plot, updated report, local commit, first plateau check, or encoded `job.py` default is a checkpoint, not completion. Treat the @@ -163,9 +156,9 @@ Campaigns are uncapped by default. If the user says "optimize this job" without an explicit candidate budget, continue the campaign until manually interrupted or blocked. Do not stop after the first baseline, first batch, first successful candidate, first kept improvement, first local commit, or first plateau -checkpoint. Do not stop after a first sweep of available CLI tunables; in -uncapped mode repeating, broadening, or generated same-budget candidates are -valid continued campaign work. Progress reports in uncapped mode must not be +checkpoint. Do not stop after a first sweep of tunables; broaden into +agent-authored code or literature-derived algorithm candidates. Progress +reports in uncapped mode must not be phrased as "should I continue?" decisions; the answer is continue unless the user explicitly interrupts or the code-owned state permits finalization. Do not invent a replacement campaign or new objective after a recoverable @@ -183,6 +176,8 @@ Treat plateau as a decision checkpoint, not an automatic stop. Summarize the plateau in the running report, refresh `progress.png`, run the campaign guard or read the runner's `.nvflare/autofl/campaign_state.json`, choose the returned next mode, and continue unless the state reports `final_response_allowed=true`. +After a source-backed review, record it with the helper's `record --literature +--hypothesis ""` action before preparing its candidate. ## Stop Handling diff --git a/skills/nvflare-autofl/evals/evals.json b/skills/nvflare-autofl/evals/evals.json index 45198bf6ab..e7dfb55e1a 100644 --- a/skills/nvflare-autofl/evals/evals.json +++ b/skills/nvflare-autofl/evals/evals.json @@ -4,11 +4,13 @@ { "id": "autofl-optimize-existing-job", "prompt": "Use NVFLARE Auto-FL to optimize ./job.py for validation accuracy in simulation with an 8-candidate budget.", - "expected_output": "The agent invokes the NVFLARE Auto-FL skill, imports job.py into autofl.yaml, summarizes editable/unresolved/allowed campaign settings, and then runs bounded candidates through existing FLARE execution surfaces.", + "expected_output": "The agent invokes the NVFLARE Auto-FL skill, imports job.py into autofl.yaml, summarizes editable/unresolved/allowed campaign settings, then prepares and evaluates agent-authored source or tunable candidates through candidate manifests and existing FLARE execution surfaces.", "files": [], "assertions": [ "The agent generates autofl.yaml before editing files.", "The agent summarizes editable settings, unresolved fields, fixed-budget constraints, and allowed edit paths.", + "The agent forms hypotheses and edits isolated candidate source rather than limiting the campaign to built-in tunable sweeps.", + "The agent records changed files, patch hash, base candidate, and artifacts in candidate_manifest.json.", "The agent runs candidates using the existing job.py rather than replacing it with autofl.yaml.", "The agent records candidate results and reports the best reproducible candidate." ], @@ -27,6 +29,14 @@ "id": "bounded-edit-surface", "description": "edits only files allowed by autofl.yaml" }, + { + "id": "agent-authored-code-candidates", + "description": "lets the coding agent implement source and algorithm candidates in an isolated draft" + }, + { + "id": "candidate-manifest-provenance", + "description": "computes a manifest with base source, changed files, patch hash, budget hash, artifacts, and result" + }, { "id": "existing-flare-execution", "description": "uses job.py and standard FLARE execution surfaces for candidate runs" @@ -44,6 +54,10 @@ { "id": "no-out-of-scope-edits", "description": "does not edit outside the allowed edit paths" + }, + { + "id": "no-default-generated-search-policy", + "description": "does not let built-in tunable suggestions replace agent candidate planning" } ], "process_metrics": [ @@ -59,6 +73,10 @@ "id": "metric_extraction_success", "description": "whether the requested metric is extracted from NVFLARE artifacts or logs" }, + { + "id": "candidate_manifest_completeness", + "description": "whether code candidates include deterministic source, budget, patch, and artifact provenance" + }, { "id": "unwanted_production_action_count", "description": "number of production submissions or policy-sensitive actions performed without explicit user context" diff --git a/skills/nvflare-autofl/references/continuous-campaigns.md b/skills/nvflare-autofl/references/continuous-campaigns.md index 60983024de..81e6c7b299 100644 --- a/skills/nvflare-autofl/references/continuous-campaigns.md +++ b/skills/nvflare-autofl/references/continuous-campaigns.md @@ -5,25 +5,23 @@ recovering from a simulator stall. The top-level skill owns the interaction contract; this file carries the operational detail that keeps the campaign from prematurely stopping. -## Runner State +## Lifecycle State -When `scripts/run_job_campaign.py` is running without `--max-candidates`, never -send Ctrl-C, interrupt the background terminal, or stop the runner because a -first sweep is complete, duplicate-cycle candidates started, a current best -looks clear, a plot/report exists, or no new obvious local axis remains. In -uncapped mode those are monitoring observations only. The user owns manual -interruption; the agent may inspect the ledger/state and report progress, but -must leave the runner active while `final_response_allowed=false`. +Each `scripts/run_job_campaign.py` lifecycle action exits with a JSON envelope; +the campaign continues through `.nvflare/autofl/campaign_state.json`. In +uncapped mode, a completed action, current best, plot, report, or exhausted local +tunable sweep is a checkpoint only. Execute `next_action` while +`final_response_allowed=false`. -Also never interrupt an uncapped runner because it attempted, skipped, or -recorded an invalid candidate proposal. Invalid proposals should be filtered by -the runner and treated as product friction to repair while preserving the -long-running optimization intent. +A prepared manifest is pending work. Edit its candidate source and evaluate it, +or abandon it explicitly; do not silently start another candidate. Invalid +drafts are product friction to repair and reevaluate, not a reason to terminate +the campaign. -During long simulations, monitor the active process plus the current +During a long `evaluate` action, monitor the process plus `autofl_runs//run.log`. A live process with no final ledger row is a -running candidate, not a reason to stop. If logs are temporarily quiet but CPU or -GPU use and the child process remain active, keep waiting. +running candidate. If logs are temporarily quiet but CPU or GPU use and the +child process remain active, keep waiting. ## Campaign Guards @@ -34,13 +32,14 @@ execute `next_action` immediately; the skill text is only the interaction layer. Common next actions: -- `finalize_pending_candidates`: finalize reviewed candidate rows and rerun the - guard. +- `edit_candidate` or `evaluate_candidate`: finish the pending candidate draft. +- `propose_candidate`: form a hypothesis, prepare its manifest, and edit the + returned candidate source directory. +- `submit_baseline` or `submit_candidate`: use the standard POC/production job + lifecycle, then call `record` with its job ID and artifacts. - `run_literature_loop`: run a short source-backed literature pass, record a non-scored `literature` row when a ledger is available, then launch the next compatible same-budget candidates. -- `launch_next_candidate` or `launch_next_candidate_batch`: choose a safe - same-budget axis and launch the next candidate or batch. After every finalized batch, run the available plateau or progress watchdog when the task provides one. If it recommends `continue`, refresh `progress.png` and @@ -48,8 +47,8 @@ keep iterating locally. If it recommends a literature or exploration mode, recor that decision in the ledger/report, refresh `progress.png`, and launch the top compatible candidate batch next. If no non-duplicate safe local axis remains, switch mode rather than stopping: broaden the search within `autofl.yaml`, run a -literature-inspired proposal pass, revisit unresolved-but-safe tunables, or let -the deterministic uncapped runner continue its generated candidate stream. +literature-inspired proposal pass, implement a compatible algorithm change, or +request deterministic tunable suggestions as seeds. ## Simulator Recovery diff --git a/skills/nvflare-autofl/scripts/campaign_guard.py b/skills/nvflare-autofl/scripts/campaign_guard.py index 9206158123..b3ccc7b658 100644 --- a/skills/nvflare-autofl/scripts/campaign_guard.py +++ b/skills/nvflare-autofl/scripts/campaign_guard.py @@ -220,7 +220,7 @@ def guard_state_for_rows( decision = "continue" reason = "continue" - next_action = "launch_next_candidate" + next_action = "propose_candidate" final_response_allowed = False if pending: @@ -257,7 +257,7 @@ def guard_state_for_rows( "then launch source-backed candidates under the same comparison budget." ) else: - instruction = "Do not produce a final answer. Launch the next same-budget candidate now." + instruction = "Do not produce a final answer. Propose and prepare the next same-budget candidate now." return { "schema_version": "nvflare.autofl.campaign_state.v1", diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index 161bc0e864..b77f650c8e 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -13,17 +13,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Run a deterministic Auto-FL campaign for an existing NVFlare job.py. +"""Manage agent-authored Auto-FL candidates for an existing NVFlare job.py. -The runner executes a baseline and comparable CLI-argument candidates, records -state and artifacts, and uses only the job plus optional job-local campaign -configuration. +The coding agent owns hypotheses and source edits. This helper snapshots the +current best source, validates and evaluates candidate manifests, restores +discarded candidates, and records reproducible campaign state and artifacts. """ from __future__ import annotations import argparse import csv +import difflib +import hashlib import importlib.util import json import os @@ -36,12 +38,13 @@ import sys import time from dataclasses import dataclass, field +from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple try: import yaml -except ImportError: # pragma: no cover - launcher installs PyYAML through NVFlare deps +except ImportError: # pragma: no cover - NVFlare installs PyYAML yaml = None @@ -55,8 +58,19 @@ "run_command", "artifacts", "failure_reason", + "candidate_manifest", + "base_candidate", + "patch_sha256", ] +CANDIDATE_MANIFEST_SCHEMA_VERSION = "nvflare.autofl.candidate.v1" +CAMPAIGN_METADATA_SCHEMA_VERSION = "nvflare.autofl.campaign.v1" +CAMPAIGN_METADATA_PATH = ".nvflare/autofl/campaign.json" +CANDIDATE_ROOT = ".nvflare/autofl/candidates" +BEST_SNAPSHOT_ROOT = ".nvflare/autofl/snapshots/best" +ALLOWED_CREATE_PATTERNS = ["**/*.py"] +RESERVED_CANDIDATE_PATH_PARTS = {".git", ".nvflare", "__pycache__", "autofl_runs"} + INFRASTRUCTURE_RETRY = "infrastructure_retry" SIMULATOR_STALL_EXIT_CODE = 125 SIMULATOR_STALL_PATTERNS = ( @@ -87,6 +101,7 @@ FIXED_BUDGET_TO_CLI = { "num_clients": "n_clients", + "min_clients": "min_clients", "num_rounds": "num_rounds", } @@ -117,6 +132,9 @@ class RunRecord: run_command: str artifacts: str failure_reason: str = "" + candidate_manifest: str = "" + base_candidate: str = "" + patch_sha256: str = "" @dataclass @@ -150,10 +168,15 @@ def env_float(name: str, default: float) -> float: def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "action", + choices=["initialize", "prepare", "evaluate", "abandon", "suggest", "record", "status"], + help="skill-internal campaign lifecycle action", + ) parser.add_argument("job", help="NVFlare job.py to optimize") parser.add_argument("--metric", default="accuracy") parser.add_argument("--mode", choices=["max", "min"], default="max") - parser.add_argument("--env", dest="target_env", choices=["sim"], default="sim") + parser.add_argument("--env", dest="target_env", choices=["sim", "poc", "prod"], default="sim") parser.add_argument( "--max-candidates", type=int, @@ -206,6 +229,17 @@ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: parser.add_argument("--prefer-synthetic", action=argparse.BooleanOptionalAction, default=True) parser.add_argument("--synthetic-train-size", type=int, default=2048) parser.add_argument("--synthetic-test-size", type=int, default=256) + parser.add_argument("--name", help="candidate name for prepare") + parser.add_argument("--hypothesis", help="candidate hypothesis for prepare") + parser.add_argument("--manifest", help="candidate_manifest.json path") + parser.add_argument("--run-args", default="", help="candidate-only job.py arguments") + parser.add_argument("--score", type=float, help="externally measured metric for record") + parser.add_argument("--artifacts", dest="external_artifacts", help="external POC/production artifacts") + parser.add_argument("--job-id", help="standard NVFlare job ID for an external result") + parser.add_argument("--failure-reason", default="", help="external execution failure") + parser.add_argument("--baseline", action="store_true", help="record an externally executed baseline") + parser.add_argument("--literature", action="store_true", help="record a literature-review checkpoint") + parser.add_argument("--limit", type=int, default=10, help="maximum fallback suggestions") return parser.parse_args(argv) @@ -481,6 +515,214 @@ def write_json(path: Path, data: Dict[str, Any]) -> None: path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") +def read_json(path: Path) -> Dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + raise ValueError(f"failed to read JSON from {path}: {e}") from e + if not isinstance(payload, dict): + raise ValueError(f"expected a JSON object in {path}") + return payload + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_json(data: Any) -> str: + return sha256_bytes(json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")) + + +def safe_relative_path(workspace: Path, value: str) -> str: + path = Path(value) + resolved = path.resolve() if path.is_absolute() else (workspace / path).resolve() + try: + relative = resolved.relative_to(workspace.resolve()) + except ValueError as e: + raise ValueError(f"path escapes the Auto-FL job workspace: {value}") from e + if not relative.parts or any(part in RESERVED_CANDIDATE_PATH_PARTS for part in relative.parts): + raise ValueError(f"path is reserved for Auto-FL or repository metadata: {value}") + return relative.as_posix() + + +def allowed_edit_paths(config: Dict[str, Any], workspace: Path) -> List[str]: + values = config.get("job", {}).get("allowed_edit_paths", []) or [] + if not isinstance(values, list): + raise ValueError("autofl.yaml job.allowed_edit_paths must be a list") + return list(dict.fromkeys(safe_relative_path(workspace, str(value)) for value in values)) + + +def is_allowed_new_source(path: str) -> bool: + relative = Path(path) + return relative.suffix == ".py" and not any(part in RESERVED_CANDIDATE_PATH_PARTS for part in relative.parts) + + +def file_map(root: Path) -> Dict[str, str]: + if not root.exists(): + return {} + files: Dict[str, str] = {} + for path in sorted(root.rglob("*")): + if path.is_symlink(): + raise ValueError(f"candidate source contains a symlink: {path}") + if path.is_file(): + relative = path.relative_to(root).as_posix() + files[relative] = sha256_bytes(path.read_bytes()) + return files + + +def source_hash(files: Dict[str, str]) -> str: + return sha256_json(files) + + +def copy_relative_file(source_root: Path, destination_root: Path, relative: str) -> None: + source = source_root / relative + destination = destination_root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + +def create_best_snapshot(workspace: Path, config: Dict[str, Any], snapshot_root: Path) -> Dict[str, str]: + if snapshot_root.exists(): + shutil.rmtree(snapshot_root) + source_root = snapshot_root / "source" + source_root.mkdir(parents=True, exist_ok=True) + for relative in allowed_edit_paths(config, workspace): + source = workspace / relative + if source.is_symlink(): + raise ValueError(f"allowed edit path is a symlink: {source}") + if source.is_file(): + copy_relative_file(workspace, source_root, relative) + files = file_map(source_root) + write_json(snapshot_root / "snapshot.json", {"source_sha256": source_hash(files), "files": files}) + return files + + +def load_best_snapshot(snapshot_root: Path) -> Tuple[Path, Dict[str, str]]: + metadata = read_json(snapshot_root / "snapshot.json") + files = metadata.get("files") + if not isinstance(files, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in files.items()): + raise ValueError("best snapshot metadata has an invalid files mapping") + source_root = snapshot_root / "source" + if source_hash(files) != metadata.get("source_sha256") or file_map(source_root) != files: + raise ValueError("best snapshot failed its integrity check") + return source_root, files + + +def workspace_matches_snapshot(workspace: Path, source_root: Path, files: Dict[str, str]) -> bool: + for relative, digest in files.items(): + path = workspace / relative + if path.is_symlink() or not path.is_file() or sha256_bytes(path.read_bytes()) != digest: + return False + return True + + +def validate_candidate_id(value: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", value or ""): + raise ValueError("candidate name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}") + return value + + +def candidate_manifest_path(workspace: Path, candidate_id: str) -> Path: + return workspace / CANDIDATE_ROOT / candidate_id / "candidate_manifest.json" + + +def load_candidate_manifest(path: Path) -> Dict[str, Any]: + manifest = read_json(path) + if manifest.get("schema_version") != CANDIDATE_MANIFEST_SCHEMA_VERSION: + raise ValueError(f"unsupported candidate manifest schema in {path}") + candidate_id = validate_candidate_id(str(manifest.get("candidate_id") or "")) + expected = candidate_manifest_path(Path(str(manifest.get("workspace_root") or "")), candidate_id).resolve() + if path.resolve() != expected: + raise ValueError("candidate manifest path does not match its workspace and candidate ID") + if manifest.get("status") not in {"prepared", "ready_for_external_execution"}: + raise ValueError(f"candidate {candidate_id} is not pending evaluation") + return manifest + + +def campaign_metadata_path(workspace: Path) -> Path: + return workspace / CAMPAIGN_METADATA_PATH + + +def load_campaign_metadata(workspace: Path, job: Path) -> Dict[str, Any]: + metadata = read_json(campaign_metadata_path(workspace)) + if metadata.get("schema_version") != CAMPAIGN_METADATA_SCHEMA_VERSION: + raise ValueError("unsupported Auto-FL campaign metadata schema") + if Path(str(metadata.get("job") or "")).resolve() != job.resolve(): + raise ValueError("campaign metadata belongs to a different job.py") + return metadata + + +def fixed_budget_hash(config: Dict[str, Any]) -> str: + return sha256_json(config.get("budget", {}).get("fixed_training_budget", {}) or {}) + + +def candidate_changes( + workspace: Path, + config: Dict[str, Any], + best_source: Path, + best_files: Dict[str, str], + draft_source: Path, +) -> Tuple[List[str], List[str]]: + draft_files = file_map(draft_source) + deleted = sorted(set(best_files) - set(draft_files)) + if deleted: + raise ValueError(f"candidate deletes managed source files: {', '.join(deleted)}") + + allowed = set(allowed_edit_paths(config, workspace)) + changed = sorted(path for path, digest in draft_files.items() if best_files.get(path) != digest) + created = [] + for relative in changed: + if relative in best_files: + if relative not in allowed: + raise ValueError(f"candidate modifies a path outside job.allowed_edit_paths: {relative}") + continue + if (workspace / relative).exists() or not is_allowed_new_source(relative): + raise ValueError(f"candidate creates an unauthorized source path: {relative}") + created.append(relative) + return changed, created + + +def text_for_diff(path: Path) -> List[str]: + data = path.read_bytes() + if b"\0" in data: + raise ValueError(f"candidate diff does not support binary file: {path}") + return data.decode("utf-8", errors="replace").splitlines(keepends=True) + + +def render_candidate_patch(best_source: Path, draft_source: Path, changed: Sequence[str]) -> str: + chunks: List[str] = [] + for relative in changed: + before = text_for_diff(best_source / relative) if (best_source / relative).is_file() else [] + after = text_for_diff(draft_source / relative) + chunks.extend( + difflib.unified_diff( + before, + after, + fromfile=f"a/{relative}", + tofile=f"b/{relative}", + ) + ) + return "".join(chunks) + + +def apply_candidate_source(workspace: Path, draft_source: Path, changed: Sequence[str]) -> None: + for relative in changed: + copy_relative_file(draft_source, workspace, relative) + + +def restore_best_source(workspace: Path, best_source: Path, best_files: Dict[str, str], changed: Sequence[str]) -> None: + for relative in changed: + destination = workspace / relative + if relative in best_files: + copy_relative_file(best_source, workspace, relative) + elif destination.exists() and not destination.is_dir(): + destination.unlink() + + _CAMPAIGN_GUARD = None @@ -706,7 +948,13 @@ def candidate_arg_values(candidate_args: Sequence[str]) -> Dict[str, Any]: if not raw.startswith("--"): idx += 1 continue - name = raw[2:].replace("-", "_") + option = raw[2:] + if "=" in option: + name, value = option.split("=", 1) + values[name.replace("-", "_")] = value + idx += 1 + continue + name = option.replace("-", "_") if idx + 1 >= len(candidate_args) or candidate_args[idx + 1].startswith("--"): values[name] = True idx += 1 @@ -756,6 +1004,19 @@ def candidate_args_allowed(candidate_args: Sequence[str], schema: Dict[str, Any] return True, "" +def candidate_preserves_fixed_args( + candidate_args: Sequence[str], config: Dict[str, Any], schema: Dict[str, Any] +) -> Tuple[bool, str]: + values = candidate_arg_values(candidate_args) + fixed_names = set(fixed_within_campaign(schema)) + fixed_budget = config.get("budget", {}).get("fixed_training_budget", {}) or {} + fixed_names.update(FIXED_BUDGET_TO_CLI[field] for field in fixed_budget if field in FIXED_BUDGET_TO_CLI) + changed = sorted(fixed_names.intersection(values)) + if changed: + return False, f"candidate run arguments change fixed-budget fields: {', '.join(changed)}" + return True, "" + + def load_mutation_schema(cwd: Path) -> Dict[str, Any]: path = cwd / "mutation_schema.yaml" if not path.exists(): @@ -784,8 +1045,8 @@ def fixed_within_campaign(schema: Dict[str, Any]) -> set: def build_comparison_budget_args(schema: Dict[str, Any], help_text: str) -> List[str]: budget = comparison_budget(schema) args: List[str] = [] - for field, cli_name in COMPARISON_BUDGET_TO_CLI.items(): - value = budget.get(field) + for budget_field, cli_name in COMPARISON_BUDGET_TO_CLI.items(): + value = budget.get(budget_field) if value is not None and supports_flag(help_text, f"--{cli_name}"): args.extend([f"--{cli_name}", str(value)]) if budget.get("cross_site_eval") and supports_flag(help_text, "--cross_site_eval"): @@ -797,13 +1058,13 @@ def build_fixed_args(config: Dict[str, Any], help_text: str, schema: Dict[str, A fixed = config.get("budget", {}).get("fixed_training_budget", {}) or {} budget = comparison_budget(schema) budget_cli_names = { - cli_name for field, cli_name in COMPARISON_BUDGET_TO_CLI.items() if budget.get(field) is not None + cli_name for budget_field, cli_name in COMPARISON_BUDGET_TO_CLI.items() if budget.get(budget_field) is not None } args: List[str] = [] - for field, cli_name in FIXED_BUDGET_TO_CLI.items(): + for budget_field, cli_name in FIXED_BUDGET_TO_CLI.items(): if cli_name in budget_cli_names: continue - value = fixed.get(field) + value = fixed.get(budget_field) if value is not None and supports_flag(help_text, f"--{cli_name}"): args.extend([f"--{cli_name}", str(value)]) return args @@ -1102,10 +1363,39 @@ def write_results(path: Path, records: List[RunRecord]) -> None: "run_command": record.run_command, "artifacts": record.artifacts, "failure_reason": record.failure_reason, + "candidate_manifest": record.candidate_manifest, + "base_candidate": record.base_candidate, + "patch_sha256": record.patch_sha256, } ) +def load_results(path: Path) -> List[RunRecord]: + if not path.exists(): + return [] + records = [] + with path.open("r", encoding="utf-8", newline="") as f: + for row in csv.DictReader(f, delimiter="\t"): + score_text = row.get("score", "") + records.append( + RunRecord( + status=row.get("status", ""), + name=row.get("name", ""), + score=float(score_text) if score_text else None, + runtime_seconds=float(row.get("runtime_seconds") or 0.0), + changed_files=row.get("changed_files", ""), + diff_summary=row.get("diff_summary", ""), + run_command=row.get("run_command", ""), + artifacts=row.get("artifacts", ""), + failure_reason=row.get("failure_reason", ""), + candidate_manifest=row.get("candidate_manifest", ""), + base_candidate=row.get("base_candidate", ""), + patch_sha256=row.get("patch_sha256", ""), + ) + ) + return records + + def better(new_score: Optional[float], old_score: Optional[float], mode: str) -> bool: if new_score is None: return False @@ -1114,25 +1404,6 @@ def better(new_score: Optional[float], old_score: Optional[float], mode: str) -> return new_score > old_score if mode == "max" else new_score < old_score -def finalize_candidates(records: List[RunRecord], mode: str) -> None: - baseline = next((r for r in records if r.status == "baseline"), None) - best_score = baseline.score if baseline else None - for record in records: - if record.status == "keep" and better(record.score, best_score, mode): - best_score = record.score - best_idx: Optional[int] = None - for idx, record in enumerate(records): - if record.status != "candidate": - continue - if better(record.score, best_score, mode): - best_score = record.score - best_idx = idx - for idx, record in enumerate(records): - if record.status != "candidate": - continue - record.status = "keep" if idx == best_idx else "discard" - - def write_state( path: Path, results_path: Path, @@ -1282,12 +1553,15 @@ def write_report(path: Path, config: Dict[str, Any], records: List[RunRecord], a "", "## Leaderboard", "", - "| Status | Name | Score | Artifacts | Notes |", - "| --- | --- | ---: | --- | --- |", + "| Status | Name | Score | Changed files | Manifest | Artifacts | Notes |", + "| --- | --- | ---: | --- | --- | --- | --- |", ] for record in records: score = "" if record.score is None else f"{record.score:.6f}" - lines.append(f"| {record.status} | {record.name} | {score} | `{record.artifacts}` | {record.diff_summary} |") + lines.append( + f"| {record.status} | {record.name} | {score} | `{record.changed_files}` | " + f"`{record.candidate_manifest}` | `{record.artifacts}` | {record.diff_summary} |" + ) lines.extend( [ "", @@ -1344,215 +1618,844 @@ def campaign_summary( return payload -def main(argv: Optional[Sequence[str]] = None) -> int: - args = parse_args(argv) - if args.max_candidates is not None and args.max_candidates < 1: - print("--max-candidates must be positive when provided", file=sys.stderr) - return 2 - if args.plateau_threshold < 1: - print("--plateau-threshold must be positive", file=sys.stderr) - return 2 - if args.plateau_min_delta < 0: - print("--plateau-min-delta must be non-negative", file=sys.stderr) - return 2 - if args.hard_crash_threshold < 0: - print("--hard-crash-threshold must be non-negative", file=sys.stderr) - return 2 - job = Path(args.job).resolve() - cwd = job.parent - autofl_yaml = resolve_output_path(cwd, args.autofl_yaml) - results = resolve_output_path(cwd, args.results) - state = resolve_output_path(cwd, args.state) - progress = resolve_output_path(cwd, args.progress) - report = resolve_output_path(cwd, args.report) - output_root = resolve_output_path(cwd, args.output_root) - stop_files = resolve_stop_files(cwd, args.stop_file) - output_root.mkdir(parents=True, exist_ok=True) - schema = load_mutation_schema(cwd) +def campaign_paths(args: argparse.Namespace, job: Path) -> Dict[str, Path]: + workspace = job.parent + return { + "workspace": workspace, + "autofl_yaml": resolve_output_path(workspace, args.autofl_yaml), + "results": resolve_output_path(workspace, args.results), + "state": resolve_output_path(workspace, args.state), + "progress": resolve_output_path(workspace, args.progress), + "report": resolve_output_path(workspace, args.report), + "output_root": resolve_output_path(workspace, args.output_root), + "snapshot_root": workspace / BEST_SNAPSHOT_ROOT, + } + + +CAMPAIGN_SETTING_NAMES = ( + "metric", + "mode", + "target_env", + "max_candidates", + "autofl_yaml", + "results", + "state", + "progress", + "report", + "output_root", + "plateau_threshold", + "plateau_min_delta", + "hard_crash_threshold", + "base_args", + "timeout", + "simulator_no_progress_timeout", + "python", + "prefer_synthetic", + "synthetic_train_size", + "synthetic_test_size", +) + + +def campaign_settings(args: argparse.Namespace) -> Dict[str, Any]: + return {name: getattr(args, name) for name in CAMPAIGN_SETTING_NAMES} + + +def restore_campaign_settings(args: argparse.Namespace, metadata: Dict[str, Any]) -> None: + settings = metadata.get("settings") + if not isinstance(settings, dict): + raise ValueError("campaign metadata is missing settings") + for name in CAMPAIGN_SETTING_NAMES: + if name in settings: + setattr(args, name, settings[name]) + + +def campaign_timeout(args: argparse.Namespace, schema: Dict[str, Any]) -> Tuple[int, int]: budget = comparison_budget(schema) budget_timeout = budget.get("run_timeout_seconds") timeout = max(args.timeout, int(budget_timeout)) if budget_timeout is not None else args.timeout budget_no_progress_timeout = budget.get("simulator_no_progress_timeout_seconds") - simulator_no_progress_timeout = ( + no_progress_timeout = ( int(budget_no_progress_timeout) if budget_no_progress_timeout is not None else args.simulator_no_progress_timeout ) + return timeout, no_progress_timeout - import_cmd = [ + +def import_job_config( + args: argparse.Namespace, + job: Path, + output: Path, + log_path: Path, + timeout: int, +) -> Dict[str, Any]: + command = [ args.python, "-m", "nvflare.app_common.autofl.job_importer", str(job), "--metric", args.metric, + "--mode", + args.mode, "--env", args.target_env, "--output", - str(autofl_yaml), + str(output), ] if args.max_candidates is not None: - import_cmd.extend(["--max-candidates", str(args.max_candidates)]) - rc, output, _ = run(import_cmd, cwd, timeout, output_root / "import.log") + command.extend(["--max-candidates", str(args.max_candidates)]) + rc, text, _ = run(command, job.parent, timeout, log_path) if rc != 0: - print(output, file=sys.stderr) - return rc + raise RuntimeError(text.strip() or f"deterministic import exited with status {rc}") + return read_yaml(output) - config = apply_metric_contract(read_yaml(autofl_yaml), args.metric, schema) - write_yaml(autofl_yaml, config) - metrics = metric_extraction_order(config, args.metric) - metric_label = optimization_metric(config, args.metric) - help_text = job_help(args.python, job, cwd) - fixed_args = build_fixed_args(config, help_text, schema) - base_args = build_base_args(args, help_text, schema) - records: List[RunRecord] = [] - baseline = JobRun(name="baseline", args=[], description="baseline", status="baseline") - baseline_record = run_job( - baseline, - python=args.python, - job=job, - cwd=cwd, - help_text=help_text, - fixed_args=fixed_args, - base_args=base_args, - output_root=output_root, - timeout=timeout, - simulator_no_progress_timeout=simulator_no_progress_timeout, - metrics=metrics, - config=config, - ) - records.append(baseline_record) - write_results(results, records) - state_payload = write_state( - state, - results, +def best_retained_record(records: Sequence[RunRecord], mode: str) -> Optional[RunRecord]: + best = None + for record in records: + if record.status in {"baseline", "keep"} and better(record.score, best.score if best else None, mode): + best = record + return best + + +def refresh_campaign_artifacts( + args: argparse.Namespace, + paths: Dict[str, Path], + config: Dict[str, Any], + records: List[RunRecord], + metadata: Dict[str, Any], + *, + pending_manifest: Optional[Path] = None, + next_action: Optional[str] = None, + reason: Optional[str] = None, +) -> Dict[str, Any]: + write_results(paths["results"], records) + state = write_state( + paths["state"], + paths["results"], records, args.max_candidates, mode=args.mode, - stop_files=stop_files, + stop_files=resolve_stop_files(paths["workspace"], args.stop_file), plateau_threshold=args.plateau_threshold, plateau_min_delta=args.plateau_min_delta, hard_crash_threshold=args.hard_crash_threshold, ) - write_progress(progress, records, args.mode, metric_label) - write_report(report, config, records, args) - if baseline_record.status == INFRASTRUCTURE_RETRY: - print( - json.dumps( - campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), - indent=2, - sort_keys=True, + if not state.get("final_response_allowed") and next_action: + state["next_action"] = next_action + state["reason"] = reason or next_action + state["agent_instruction"] = f"Do not produce a final answer. Execute `{next_action}` for this campaign." + state.update( + { + "best_candidate": metadata.get("best_candidate"), + "best_source_sha256": metadata.get("best_source_sha256"), + "pending_candidate_manifest": str(pending_manifest.resolve()) if pending_manifest else None, + } + ) + write_json(paths["state"], state) + write_progress(paths["progress"], records, args.mode, optimization_metric(config, args.metric)) + write_report(paths["report"], config, records, args) + return state + + +def print_campaign_result( + paths: Dict[str, Path], records: List[RunRecord], state: Dict[str, Any], **extra: Any +) -> None: + payload = campaign_summary( + paths["autofl_yaml"], + paths["results"], + paths["state"], + paths["progress"], + paths["report"], + records, + state, + ) + payload.update(extra) + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def execute_sim_baseline( + args: argparse.Namespace, + job: Path, + paths: Dict[str, Path], + config: Dict[str, Any], + schema: Dict[str, Any], +) -> RunRecord: + timeout, no_progress_timeout = campaign_timeout(args, schema) + help_text = job_help(args.python, job, job.parent) + return run_job( + JobRun(name="baseline", args=[], description="baseline", status="baseline"), + python=args.python, + job=job, + cwd=job.parent, + help_text=help_text, + fixed_args=build_fixed_args(config, help_text, schema), + base_args=build_base_args(args, help_text, schema), + output_root=paths["output_root"], + timeout=timeout, + simulator_no_progress_timeout=no_progress_timeout, + metrics=metric_extraction_order(config, args.metric), + config=config, + ) + + +def initialize_campaign(args: argparse.Namespace, job: Path) -> int: + workspace = job.parent + metadata_path = campaign_metadata_path(workspace) + if metadata_path.exists(): + metadata = load_campaign_metadata(workspace, job) + restore_campaign_settings(args, metadata) + paths = campaign_paths(args, job) + records = load_results(paths["results"]) + if args.target_env == "sim" and not any( + record.status == "baseline" and record.score is not None for record in records + ): + config = read_yaml(paths["autofl_yaml"]) + baseline = execute_sim_baseline(args, job, paths, config, load_mutation_schema(workspace)) + records = [baseline] + metadata["best_score"] = baseline.score + metadata["updated_at"] = utc_now() + write_json(metadata_path, metadata) + next_action = "propose_candidate" if baseline.score is not None else "repair_baseline" + state = refresh_campaign_artifacts( + args, paths, config, records, metadata, next_action=next_action, reason="baseline_retried" ) - ) + print_campaign_result(paths, records, state, initialized=False, baseline_retried=True) + if baseline.status == INFRASTRUCTURE_RETRY: + return 75 + return 0 if baseline.score is not None else 1 + print_campaign_result(paths, records, read_json(paths["state"]), initialized=False) + return 0 + + paths = campaign_paths(args, job) + paths["output_root"].mkdir(parents=True, exist_ok=True) + schema = load_mutation_schema(workspace) + timeout, _ = campaign_timeout(args, schema) + config = import_job_config(args, job, paths["autofl_yaml"], paths["output_root"] / "import.log", timeout) + config = apply_metric_contract(config, args.metric, schema) + config.setdefault("job", {})["allowed_create_patterns"] = list(ALLOWED_CREATE_PATTERNS) + config.setdefault("trust_contract", {})["allowed_create_patterns"] = list(ALLOWED_CREATE_PATTERNS) + write_yaml(paths["autofl_yaml"], config) + snapshot_files = create_best_snapshot(workspace, config, paths["snapshot_root"]) + metadata = { + "schema_version": CAMPAIGN_METADATA_SCHEMA_VERSION, + "created_at": utc_now(), + "updated_at": utc_now(), + "job": str(job.resolve()), + "workspace_root": str(workspace.resolve()), + "settings": campaign_settings(args), + "best_candidate": "baseline", + "best_score": None, + "best_source_sha256": source_hash(snapshot_files), + "fixed_budget_sha256": fixed_budget_hash(config), + } + write_json(metadata_path, metadata) + + records: List[RunRecord] = [] + if args.target_env == "sim": + baseline = execute_sim_baseline(args, job, paths, config, schema) + records.append(baseline) + metadata["best_score"] = baseline.score + metadata["updated_at"] = utc_now() + write_json(metadata_path, metadata) + next_action = "propose_candidate" if baseline.score is not None else "repair_baseline" + else: + next_action = "submit_baseline" + + state = refresh_campaign_artifacts( + args, paths, config, records, metadata, next_action=next_action, reason="campaign_initialized" + ) + print_campaign_result(paths, records, state, initialized=True) + if records and records[0].status == INFRASTRUCTURE_RETRY: return 75 - if baseline_record.score is None: - write_report(report, config, records, args) - print( - f"Baseline run did not produce metrics '{', '.join(metrics)}'. See {baseline_record.artifacts}", - file=sys.stderr, - ) + if args.target_env == "sim" and (not records or records[0].score is None): return 1 + return 0 + + +def pending_candidate_manifests(workspace: Path) -> List[Path]: + root = workspace / CANDIDATE_ROOT + pending = [] + if not root.exists(): + return pending + for path in sorted(root.glob("*/candidate_manifest.json")): + try: + status = read_json(path).get("status") + except ValueError: + continue + if status in {"prepared", "ready_for_external_execution"}: + pending.append(path) + return pending + + +def prepare_candidate(args: argparse.Namespace, job: Path) -> int: + workspace = job.parent + metadata = load_campaign_metadata(workspace, job) + restore_campaign_settings(args, metadata) + paths = campaign_paths(args, job) + records = load_results(paths["results"]) + if not any(record.status == "baseline" and record.score is not None for record in records): + raise ValueError("a scored baseline is required before preparing candidates") + state = read_json(paths["state"]) + if state.get("final_response_allowed"): + raise ValueError(f"campaign is already final: {state.get('reason')}") + pending = pending_candidate_manifests(workspace) + if pending: + raise ValueError(f"campaign already has a pending candidate: {pending[0]}") + candidate_id = validate_candidate_id(args.name or "") + if not args.hypothesis: + raise ValueError("--hypothesis is required for prepare") + manifest_path = candidate_manifest_path(workspace, candidate_id) + candidate_dir = manifest_path.parent + if candidate_dir.exists(): + raise ValueError(f"candidate already exists: {candidate_id}") + + best_source, best_files = load_best_snapshot(paths["snapshot_root"]) + if source_hash(best_files) != metadata.get("best_source_sha256"): + raise ValueError("campaign best-source hash is stale") + if not workspace_matches_snapshot(workspace, best_source, best_files): + raise ValueError("job workspace differs from the recorded best candidate; reconcile edits before preparing") + + draft_source = candidate_dir / "source" + shutil.copytree(best_source, draft_source) + manifest = { + "schema_version": CANDIDATE_MANIFEST_SCHEMA_VERSION, + "candidate_id": candidate_id, + "name": candidate_id, + "hypothesis": args.hypothesis, + "status": "prepared", + "created_at": utc_now(), + "updated_at": utc_now(), + "workspace_root": str(workspace.resolve()), + "base_candidate": metadata.get("best_candidate"), + "base_source_sha256": source_hash(best_files), + "fixed_budget_sha256": metadata.get("fixed_budget_sha256"), + "objective": {"metric": args.metric, "mode": args.mode}, + "environment": args.target_env, + "run_args": shlex.split(args.run_args), + "changed_files": [], + "patch_sha256": "", + "candidate_source_sha256": source_hash(best_files), + "provenance": { + "job": str(job.resolve()), + "autofl_yaml": str(paths["autofl_yaml"].resolve()), + "import_source_sha256": read_yaml(paths["autofl_yaml"]).get("import", {}).get("source_sha256"), + }, + "artifacts": {}, + "result": {}, + } + write_json(manifest_path, manifest) + config = read_yaml(paths["autofl_yaml"]) + state = refresh_campaign_artifacts( + args, + paths, + config, + records, + metadata, + pending_manifest=manifest_path, + next_action="edit_candidate", + reason="candidate_prepared", + ) + print_campaign_result( + paths, + records, + state, + candidate_manifest=str(manifest_path.resolve()), + candidate_source=str(draft_source.resolve()), + ) + return 0 + +def validate_candidate_for_evaluation( + args: argparse.Namespace, + job: Path, + metadata: Dict[str, Any], + paths: Dict[str, Path], + manifest_path: Path, +) -> Tuple[Dict[str, Any], Dict[str, Any], Path, Dict[str, str], List[str], List[str], str]: + manifest = load_candidate_manifest(manifest_path) + if Path(str(manifest.get("workspace_root") or "")).resolve() != job.parent.resolve(): + raise ValueError("candidate manifest belongs to a different job workspace") + config = read_yaml(paths["autofl_yaml"]) + best_source, best_files = load_best_snapshot(paths["snapshot_root"]) + best_hash = source_hash(best_files) + if manifest.get("base_source_sha256") != best_hash or metadata.get("best_source_sha256") != best_hash: + raise ValueError("candidate was prepared from a stale best candidate") + if manifest.get("fixed_budget_sha256") != metadata.get("fixed_budget_sha256"): + raise ValueError("candidate fixed-budget provenance is stale") + if not workspace_matches_snapshot(job.parent, best_source, best_files): + raise ValueError("job workspace differs from the recorded best candidate") + draft_source = manifest_path.parent / "source" + changed, created = candidate_changes(job.parent, config, best_source, best_files, draft_source) + run_args = manifest.get("run_args") + if not isinstance(run_args, list) or not all(isinstance(item, str) for item in run_args): + raise ValueError("candidate manifest run_args must be a list of strings") + allowed, reason = candidate_args_allowed(run_args, load_mutation_schema(job.parent)) + if not allowed: + raise ValueError(reason) + allowed, reason = candidate_preserves_fixed_args(run_args, config, load_mutation_schema(job.parent)) + if not allowed: + raise ValueError(reason) + if not changed and not run_args: + raise ValueError("candidate has no source changes or run arguments") + patch = render_candidate_patch(best_source, draft_source, changed) + return manifest, config, best_source, best_files, changed, created, patch + + +def update_config_for_kept_sources(config: Dict[str, Any], created: Sequence[str]) -> None: + if not created: + return + job_paths = config.setdefault("job", {}).setdefault("allowed_edit_paths", []) + trust_paths = config.setdefault("trust_contract", {}).setdefault("allowed_edit_paths", []) + for relative in created: + if relative not in job_paths: + job_paths.append(relative) + if relative not in trust_paths: + trust_paths.append(relative) + + +def candidate_campaign_config( + candidate_config: Dict[str, Any], current_config: Dict[str, Any], args: argparse.Namespace, schema: Dict[str, Any] +) -> Dict[str, Any]: + candidate_config = apply_metric_contract(candidate_config, args.metric, schema) + current_paths = current_config.get("job", {}).get("allowed_edit_paths", []) or [] + candidate_paths = candidate_config.setdefault("job", {}).setdefault("allowed_edit_paths", []) + trust_paths = candidate_config.setdefault("trust_contract", {}).setdefault("allowed_edit_paths", []) + for path in current_paths: + if path not in candidate_paths: + candidate_paths.append(path) + if path not in trust_paths: + trust_paths.append(path) + create_patterns = current_config.get("job", {}).get("allowed_create_patterns", ALLOWED_CREATE_PATTERNS) + candidate_config["job"]["allowed_create_patterns"] = list(create_patterns) + candidate_config["trust_contract"]["allowed_create_patterns"] = list(create_patterns) + return candidate_config + + +def finalize_candidate_result( + args: argparse.Namespace, + job: Path, + metadata: Dict[str, Any], + paths: Dict[str, Path], + config: Dict[str, Any], + manifest_path: Path, + manifest: Dict[str, Any], + best_source: Path, + best_files: Dict[str, str], + changed: List[str], + created: List[str], + patch: str, + record: RunRecord, +) -> Tuple[List[RunRecord], Dict[str, Any]]: + records = load_results(paths["results"]) + previous_best = best_retained_record(records, args.mode) + if record.status == "candidate": + record.status = ( + "keep" if better(record.score, previous_best.score if previous_best else None, args.mode) else "discard" + ) + patch_path = manifest_path.parent / "candidate.patch" + patch_path.write_text(patch, encoding="utf-8") + patch_sha256 = sha256_bytes(patch.encode("utf-8")) + record.changed_files = ",".join(changed) if changed else "none" + record.diff_summary = str(manifest.get("hypothesis") or "candidate") + record.candidate_manifest = str(manifest_path.resolve()) + record.base_candidate = str(manifest.get("base_candidate") or "") + record.patch_sha256 = patch_sha256 + + if record.status == "keep": + update_config_for_kept_sources(config, created) + write_yaml(paths["autofl_yaml"], config) + snapshot_files = create_best_snapshot(job.parent, config, paths["snapshot_root"]) + metadata.update( + { + "best_candidate": record.name, + "best_score": record.score, + "best_source_sha256": source_hash(snapshot_files), + "updated_at": utc_now(), + } + ) + else: + restore_best_source(job.parent, best_source, best_files, changed) + + manifest.update( + { + "status": record.status, + "updated_at": utc_now(), + "changed_files": changed, + "created_files": created, + "patch_sha256": patch_sha256, + "artifacts": {"patch": str(patch_path.resolve()), "run": record.artifacts}, + "result": { + "score": record.score, + "runtime_seconds": record.runtime_seconds, + "run_command": record.run_command, + "failure_reason": record.failure_reason, + }, + } + ) + write_json(manifest_path, manifest) + write_json(campaign_metadata_path(job.parent), metadata) + records.append(record) + state = refresh_campaign_artifacts(args, paths, config, records, metadata) + return records, state + + +def evaluate_candidate(args: argparse.Namespace, job: Path) -> int: + workspace = job.parent + metadata = load_campaign_metadata(workspace, job) + restore_campaign_settings(args, metadata) + paths = campaign_paths(args, job) + manifest_path = Path(args.manifest).resolve() if args.manifest else None + if manifest_path is None: + pending = pending_candidate_manifests(workspace) + if len(pending) != 1: + raise ValueError("--manifest is required when there is not exactly one pending candidate") + manifest_path = pending[0] + manifest, config, best_source, best_files, changed, created, patch = validate_candidate_for_evaluation( + args, job, metadata, paths, manifest_path + ) + patch_path = manifest_path.parent / "candidate.patch" + patch_path.write_text(patch, encoding="utf-8") + manifest.update( + { + "updated_at": utc_now(), + "changed_files": changed, + "created_files": created, + "patch_sha256": sha256_bytes(patch.encode("utf-8")), + "candidate_source_sha256": source_hash(file_map(manifest_path.parent / "source")), + } + ) + write_json(manifest_path, manifest) + apply_candidate_source(workspace, manifest_path.parent / "source", changed) + + schema = load_mutation_schema(workspace) + timeout, no_progress_timeout = campaign_timeout(args, schema) + candidate_config_path = manifest_path.parent / "candidate_autofl.yaml" try: - for candidate in candidate_plan(config, help_text, args.max_candidates, schema): - record = run_job( - candidate, - python=args.python, - job=job, - cwd=cwd, - help_text=help_text, - fixed_args=fixed_args, - base_args=base_args, - output_root=output_root, - timeout=timeout, - simulator_no_progress_timeout=simulator_no_progress_timeout, - metrics=metrics, - config=config, - ) - records.append(record) - finalize_candidates(records, args.mode) - write_results(results, records) - state_payload = write_state( - state, - results, - records, - args.max_candidates, - mode=args.mode, - stop_files=stop_files, - plateau_threshold=args.plateau_threshold, - plateau_min_delta=args.plateau_min_delta, - hard_crash_threshold=args.hard_crash_threshold, - ) - write_progress(progress, records, args.mode, metric_label) - write_report(report, config, records, args) - if record.status == INFRASTRUCTURE_RETRY: - print( - json.dumps( - campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), - indent=2, - sort_keys=True, - ) - ) - return 75 - if state_payload.get("next_action") == "run_literature_loop": - print( - json.dumps( - campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), - indent=2, - sort_keys=True, - ) - ) - return 0 - if state_payload.get("final_response_allowed"): - break - except KeyboardInterrupt: - write_results(results, records) - state_payload = write_state( - state, - results, + candidate_config = import_job_config( + args, + job, + candidate_config_path, + manifest_path.parent / "import.log", + timeout, + ) + if fixed_budget_hash(candidate_config) != metadata.get("fixed_budget_sha256"): + raise ValueError("candidate changes budget.fixed_training_budget") + candidate_config = candidate_campaign_config(candidate_config, config, args, schema) + except Exception: + restore_best_source(workspace, best_source, best_files, changed) + raise + + if args.target_env != "sim": + manifest["status"] = "ready_for_external_execution" + manifest["updated_at"] = utc_now() + write_json(manifest_path, manifest) + records = load_results(paths["results"]) + state = refresh_campaign_artifacts( + args, + paths, + config, records, - args.max_candidates, - mode=args.mode, - stop_files=stop_files, - plateau_threshold=args.plateau_threshold, - plateau_min_delta=args.plateau_min_delta, - hard_crash_threshold=args.hard_crash_threshold, - manual_stop=True, + metadata, + pending_manifest=manifest_path, + next_action="submit_candidate", + reason="candidate_validated", ) - write_progress(progress, records, args.mode, metric_label) - write_report(report, config, records, args) - print( - json.dumps( - campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), - indent=2, - sort_keys=True, - ) + print_campaign_result( + paths, + records, + state, + candidate_manifest=str(manifest_path.resolve()), + job=str(job.resolve()), ) - return 130 + return 0 - state_payload = write_state( - state, - results, + help_text = job_help(args.python, job, workspace) + try: + run_record = run_job( + JobRun( + name=str(manifest["candidate_id"]), + args=list(manifest.get("run_args") or []), + description=str(manifest.get("hypothesis") or "candidate"), + ), + python=args.python, + job=job, + cwd=workspace, + help_text=help_text, + fixed_args=build_fixed_args(config, help_text, schema), + base_args=build_base_args(args, help_text, schema), + output_root=paths["output_root"], + timeout=timeout, + simulator_no_progress_timeout=no_progress_timeout, + metrics=metric_extraction_order(config, args.metric), + config=config, + ) + except BaseException: + restore_best_source(workspace, best_source, best_files, changed) + raise + if run_record.status == INFRASTRUCTURE_RETRY: + restore_best_source(workspace, best_source, best_files, changed) + manifest["status"] = "prepared" + manifest["result"] = {"failure_reason": run_record.failure_reason} + manifest["updated_at"] = utc_now() + write_json(manifest_path, manifest) + records = load_results(paths["results"]) + state = refresh_campaign_artifacts( + args, + paths, + config, + records, + metadata, + pending_manifest=manifest_path, + next_action="rerun_with_escalated_execution", + reason="infrastructure_retry", + ) + print_campaign_result(paths, records, state, candidate_manifest=str(manifest_path.resolve())) + return 75 + + records, state = finalize_candidate_result( + args, + job, + metadata, + paths, + candidate_config, + manifest_path, + manifest, + best_source, + best_files, + changed, + created, + patch, + run_record, + ) + print_campaign_result(paths, records, state, candidate_manifest=str(manifest_path.resolve())) + return 0 + + +def abandon_candidate(args: argparse.Namespace, job: Path) -> int: + workspace = job.parent + metadata = load_campaign_metadata(workspace, job) + restore_campaign_settings(args, metadata) + paths = campaign_paths(args, job) + manifest_path = Path(args.manifest).resolve() if args.manifest else None + if manifest_path is None: + pending = pending_candidate_manifests(workspace) + if len(pending) != 1: + raise ValueError("--manifest is required when there is not exactly one pending candidate") + manifest_path = pending[0] + manifest = load_candidate_manifest(manifest_path) + best_source, best_files = load_best_snapshot(paths["snapshot_root"]) + changed = manifest.get("changed_files") or [] + if not isinstance(changed, list): + raise ValueError("candidate manifest changed_files must be a list") + restore_best_source(workspace, best_source, best_files, changed) + manifest["status"] = "abandoned" + manifest["updated_at"] = utc_now() + write_json(manifest_path, manifest) + records = load_results(paths["results"]) + config = read_yaml(paths["autofl_yaml"]) + state = refresh_campaign_artifacts( + args, + paths, + config, records, - args.max_candidates, - mode=args.mode, - stop_files=stop_files, - plateau_threshold=args.plateau_threshold, - plateau_min_delta=args.plateau_min_delta, - hard_crash_threshold=args.hard_crash_threshold, + metadata, + next_action="propose_candidate", + reason="candidate_abandoned", ) - write_progress(progress, records, args.mode, metric_label) - write_report(report, config, records, args) - print( - json.dumps( - campaign_summary(autofl_yaml, results, state, progress, report, records, state_payload), - indent=2, - sort_keys=True, + print_campaign_result(paths, records, state, candidate_manifest=str(manifest_path.resolve())) + return 0 + + +def suggest_candidates(args: argparse.Namespace, job: Path) -> int: + metadata = load_campaign_metadata(job.parent, job) + restore_campaign_settings(args, metadata) + if args.limit < 1: + raise ValueError("--limit must be positive") + config = read_yaml(campaign_paths(args, job)["autofl_yaml"]) + help_text = job_help(args.python, job, job.parent) + suggestions = [ + {"name": candidate.name, "run_args": candidate.args, "hypothesis": candidate.description} + for candidate in candidate_plan( + config, + help_text, + args.limit, + load_mutation_schema(job.parent), ) + ] + print(json.dumps({"suggestions": suggestions}, indent=2, sort_keys=True)) + return 0 + + +def record_external_result(args: argparse.Namespace, job: Path) -> int: + workspace = job.parent + metadata = load_campaign_metadata(workspace, job) + restore_campaign_settings(args, metadata) + paths = campaign_paths(args, job) + config = read_yaml(paths["autofl_yaml"]) + artifact_path = Path(args.external_artifacts).resolve() if args.external_artifacts else None + score = args.score + if score is None and artifact_path: + score = extract_score(artifact_path, metric_extraction_order(config, args.metric)) + if args.literature: + records = load_results(paths["results"]) + name = f"literature_review_{sum(record.status == 'literature' for record in records) + 1}" + records.append( + RunRecord( + status="literature", + name=name, + score=None, + runtime_seconds=0.0, + changed_files="none", + diff_summary=args.hypothesis or "source-backed literature review", + run_command="agent literature review", + artifacts=str(artifact_path or ""), + ) + ) + state = refresh_campaign_artifacts( + args, + paths, + config, + records, + metadata, + next_action="propose_candidate", + reason="literature_review_recorded", + ) + print_campaign_result(paths, records, state, literature_event=name) + return 0 + if args.baseline: + records = load_results(paths["results"]) + if records: + raise ValueError("external baseline can only be recorded before campaign candidates") + if score is None: + raise ValueError("a score or extractable --artifacts path is required for the baseline") + record = RunRecord( + status="baseline", + name="baseline", + score=score, + runtime_seconds=0.0, + changed_files="none", + diff_summary="external baseline", + run_command=f"nvflare job id={args.job_id or 'unreported'}", + artifacts=str(artifact_path or ""), + ) + records.append(record) + metadata["best_score"] = score + metadata["updated_at"] = utc_now() + write_json(campaign_metadata_path(workspace), metadata) + state = refresh_campaign_artifacts( + args, + paths, + config, + records, + metadata, + next_action="propose_candidate", + reason="baseline_recorded", + ) + print_campaign_result(paths, records, state, job_id=args.job_id) + return 0 + + manifest_path = Path(args.manifest).resolve() if args.manifest else None + if manifest_path is None: + pending = pending_candidate_manifests(workspace) + if len(pending) != 1: + raise ValueError("--manifest is required when there is not exactly one pending candidate") + manifest_path = pending[0] + manifest = load_candidate_manifest(manifest_path) + if manifest.get("status") != "ready_for_external_execution": + raise ValueError("external candidate must be validated before its result is recorded") + draft_source = manifest_path.parent / "source" + draft_files = file_map(draft_source) + if source_hash(draft_files) != manifest.get("candidate_source_sha256") or not workspace_matches_snapshot( + workspace, draft_source, draft_files + ): + raise ValueError("materialized candidate source changed after validation") + best_source, best_files = load_best_snapshot(paths["snapshot_root"]) + changed = manifest.get("changed_files") or [] + created = manifest.get("created_files") or [] + if not isinstance(changed, list) or not isinstance(created, list): + raise ValueError("candidate manifest source lists must be arrays") + patch_path = manifest_path.parent / "candidate.patch" + patch = patch_path.read_text(encoding="utf-8") if patch_path.exists() else "" + candidate_config_path = manifest_path.parent / "candidate_autofl.yaml" + candidate_config = read_yaml(candidate_config_path) if candidate_config_path.exists() else config + candidate_config = candidate_campaign_config(candidate_config, config, args, load_mutation_schema(workspace)) + status = "crash" if args.failure_reason or score is None else "candidate" + record = RunRecord( + status=status, + name=str(manifest["candidate_id"]), + score=score, + runtime_seconds=0.0, + changed_files="none", + diff_summary=str(manifest.get("hypothesis") or "candidate"), + run_command=f"nvflare job id={args.job_id or 'unreported'}", + artifacts=str(artifact_path or ""), + failure_reason=args.failure_reason or ("metric not found" if score is None else ""), + ) + records, state = finalize_candidate_result( + args, + job, + metadata, + paths, + candidate_config, + manifest_path, + manifest, + best_source, + best_files, + changed, + created, + patch, + record, ) + updated_manifest = read_json(manifest_path) + updated_manifest.setdefault("artifacts", {})["job_id"] = args.job_id + write_json(manifest_path, updated_manifest) + print_campaign_result(paths, records, state, candidate_manifest=str(manifest_path.resolve()), job_id=args.job_id) return 0 +def show_campaign_status(args: argparse.Namespace, job: Path) -> int: + metadata = load_campaign_metadata(job.parent, job) + restore_campaign_settings(args, metadata) + paths = campaign_paths(args, job) + records = load_results(paths["results"]) + print_campaign_result(paths, records, read_json(paths["state"]), campaign=metadata) + return 0 + + +def validate_args(args: argparse.Namespace) -> None: + if args.max_candidates is not None and args.max_candidates < 1: + raise ValueError("--max-candidates must be positive when provided") + if args.plateau_threshold < 1: + raise ValueError("--plateau-threshold must be positive") + if args.plateau_min_delta < 0: + raise ValueError("--plateau-min-delta must be non-negative") + if args.hard_crash_threshold < 0: + raise ValueError("--hard-crash-threshold must be non-negative") + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + try: + validate_args(args) + job = Path(args.job).resolve() + if not job.is_file(): + raise ValueError(f"job.py does not exist: {job}") + actions = { + "initialize": initialize_campaign, + "prepare": prepare_candidate, + "evaluate": evaluate_candidate, + "abandon": abandon_candidate, + "suggest": suggest_candidates, + "record": record_external_result, + "status": show_campaign_status, + } + return actions[args.action](args, job) + except (OSError, RuntimeError, ValueError) as e: + print(f"Auto-FL {args.action} failed: {e}", file=sys.stderr) + return 2 + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/unit_test/app_common/autofl/job_importer_test.py b/tests/unit_test/app_common/autofl/job_importer_test.py index ee2a47dc13..d61a179ee9 100644 --- a/tests/unit_test/app_common/autofl/job_importer_test.py +++ b/tests/unit_test/app_common/autofl/job_importer_test.py @@ -125,6 +125,8 @@ def test_import_recipe_job_extracts_trust_contract_without_executing_code(tmp_pa assert config["search_space"]["suggested"]["lr"]["default"] == 0.01 assert config["search_space"]["suggested"]["batch_size"]["type"] == "int" assert config["trust_contract"]["allowed_edit_paths"] == ["job.py", "client.py", "model.py"] + assert config["job"]["allowed_create_patterns"] == ["**/*.py"] + assert config["trust_contract"]["allowed_create_patterns"] == ["**/*.py"] assert config["trust_contract"]["agent_controls"]["must_not_edit_outside_allowed_paths"] is True assert config["unresolved"] == [] diff --git a/tests/unit_test/tool/autofl_skill_campaign_guard_test.py b/tests/unit_test/tool/autofl_skill_campaign_guard_test.py index ec3daafaef..d3be2da472 100644 --- a/tests/unit_test/tool/autofl_skill_campaign_guard_test.py +++ b/tests/unit_test/tool/autofl_skill_campaign_guard_test.py @@ -29,6 +29,9 @@ "run_command", "artifacts", "failure_reason", + "candidate_manifest", + "base_candidate", + "patch_sha256", ] @@ -61,6 +64,9 @@ def _row(status, name, score="", diff_summary="candidate", run_command="python j "run_command": run_command, "artifacts": "/tmp/run", "failure_reason": "", + "candidate_manifest": "", + "base_candidate": "", + "patch_sha256": "", } @@ -76,7 +82,7 @@ def test_guard_continues_uncapped_before_plateau(): assert state["schema_version"] == "nvflare.autofl.campaign_state.v1" assert state["decision"] == "continue" - assert state["next_action"] == "launch_next_candidate" + assert state["next_action"] == "propose_candidate" assert state["final_response_allowed"] is False assert state["candidate_cap"] is None assert state["candidate_cap_source"] == "uncapped" @@ -109,7 +115,7 @@ def test_guard_literature_event_resets_plateau_clock(): state = guard.guard_state_for_rows(rows, plateau_threshold=4) assert state["reason"] == "continue" - assert state["next_action"] == "launch_next_candidate" + assert state["next_action"] == "propose_candidate" assert state["plateau"]["last_literature_event_index"] == 5 assert state["plateau"]["scored_since_reset"] == 2 diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index 4cbb6f258d..816b6b48ae 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -14,10 +14,15 @@ import importlib.util import json +import os +import subprocess import sys +from copy import deepcopy from pathlib import Path from types import SimpleNamespace +import pytest + def _load_runner(): repo_root = Path(__file__).parents[3] @@ -29,6 +34,52 @@ def _load_runner(): return module +def _campaign_config(): + return { + "schema_version": "nvflare.autofl.config.v1", + "import": {"source_sha256": "a" * 64}, + "job": {"allowed_edit_paths": ["job.py", "client.py"]}, + "objective": { + "metric": "accuracy", + "requested_metric": "accuracy", + "optimization_metric": "accuracy", + "metric_extraction_order": ["accuracy"], + }, + "budget": {"fixed_training_budget": {"num_clients": 2, "num_rounds": 1}}, + "environment": {"requested": "sim"}, + "search_space": {"suggested": {"lr": {"default": 0.1}}}, + "trust_contract": {"allowed_edit_paths": ["job.py", "client.py"], "unresolved": []}, + } + + +def _initialize_fake_campaign(runner, tmp_path, monkeypatch, *, target_env="sim", baseline_score=0.5): + job = tmp_path / "job.py" + client = tmp_path / "client.py" + job.write_text("print('job')\n", encoding="utf-8") + client.write_text("ALGORITHM = 'baseline'\n", encoding="utf-8") + config = _campaign_config() + config["environment"]["requested"] = target_env + monkeypatch.setattr(runner, "import_job_config", lambda *args, **kwargs: deepcopy(config)) + monkeypatch.setattr(runner, "job_help", lambda *args, **kwargs: "") + + def fake_run(run_def, **kwargs): + return runner.RunRecord( + run_def.status, + run_def.name, + baseline_score, + 1.0, + "none", + run_def.description, + "python job.py", + str(tmp_path / "artifacts" / run_def.name), + ) + + monkeypatch.setattr(runner, "run_job", fake_run) + argv = ["initialize", str(job), "--env", target_env, "--no-prefer-synthetic"] + assert runner.main(argv) == 0 + return job, client, config + + def test_candidate_args_respect_mutation_schema_bounds(): runner = _load_runner() schema = {"mutable_args": {"lr": {"type": "float", "min": 0.0001, "max": 0.1}}} @@ -40,6 +91,15 @@ def test_candidate_args_respect_mutation_schema_bounds(): assert "above schema max" in reason +def test_candidate_run_args_cannot_override_fixed_budget(): + runner = _load_runner() + config = {"budget": {"fixed_training_budget": {"num_clients": 8, "num_rounds": 20}}} + + assert runner.candidate_preserves_fixed_args(["--lr", "0.01"], config, {}) == (True, "") + assert runner.candidate_preserves_fixed_args(["--num_rounds=2"], config, {})[0] is False + assert runner.candidate_preserves_fixed_args(["--n-clients", "4"], config, {})[0] is False + + def test_candidate_plan_skips_out_of_bounds_learning_rate(): runner = _load_runner() config = {"search_space": {"suggested": {"lr": {"default": 0.05}}}} @@ -333,3 +393,329 @@ def test_runner_state_marks_infrastructure_retry_non_final(tmp_path): assert state["reason"] == "infrastructure_retry" assert state["next_action"] == "rerun_with_escalated_execution" assert state["final_response_allowed"] is False + + +def test_code_candidate_keeps_improvement_and_restores_discard_without_git(tmp_path, monkeypatch): + runner = _load_runner() + job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + + assert runner.main(["prepare", str(job), "--name", "new_algo", "--hypothesis", "add a new algorithm"]) == 0 + draft = tmp_path / ".nvflare" / "autofl" / "candidates" / "new_algo" / "source" + draft.joinpath("client.py").write_text("from new_algorithm import VALUE\n", encoding="utf-8") + draft.joinpath("new_algorithm.py").write_text("VALUE = 'improved'\n", encoding="utf-8") + + def improved_run(run_def, **kwargs): + return runner.RunRecord( + "candidate", run_def.name, 0.7, 2.0, "none", run_def.description, "python job.py", "/tmp/new_algo" + ) + + monkeypatch.setattr(runner, "run_job", improved_run) + assert runner.main(["evaluate", str(job)]) == 0 + assert client.read_text(encoding="utf-8") == "from new_algorithm import VALUE\n" + assert tmp_path.joinpath("new_algorithm.py").read_text(encoding="utf-8") == "VALUE = 'improved'\n" + + kept_manifest = json.loads( + tmp_path.joinpath(".nvflare/autofl/candidates/new_algo/candidate_manifest.json").read_text(encoding="utf-8") + ) + assert kept_manifest["status"] == "keep" + assert kept_manifest["changed_files"] == ["client.py", "new_algorithm.py"] + assert kept_manifest["patch_sha256"] + + assert runner.main(["prepare", str(job), "--name", "bad_algo", "--hypothesis", "try a regression"]) == 0 + bad_draft = tmp_path / ".nvflare" / "autofl" / "candidates" / "bad_algo" / "source" + bad_draft.joinpath("client.py").write_text("ALGORITHM = 'regression'\n", encoding="utf-8") + + def regressed_run(run_def, **kwargs): + return runner.RunRecord( + "candidate", run_def.name, 0.3, 2.0, "none", run_def.description, "python job.py", "/tmp/bad_algo" + ) + + monkeypatch.setattr(runner, "run_job", regressed_run) + assert runner.main(["evaluate", str(job)]) == 0 + assert client.read_text(encoding="utf-8") == "from new_algorithm import VALUE\n" + records = runner.load_results(tmp_path / "results.tsv") + assert [record.status for record in records] == ["baseline", "keep", "discard"] + assert records[1].changed_files == "client.py,new_algorithm.py" + assert records[1].candidate_manifest.endswith("candidate_manifest.json") + + +def test_candidate_rejects_unauthorized_existing_source_and_symlink(tmp_path, monkeypatch): + runner = _load_runner() + job, _, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + tmp_path.joinpath("secret.py").write_text("SECRET = True\n", encoding="utf-8") + assert runner.main(["prepare", str(job), "--name", "unsafe", "--hypothesis", "touch secret"]) == 0 + draft = tmp_path / ".nvflare" / "autofl" / "candidates" / "unsafe" / "source" + draft.joinpath("secret.py").write_text("SECRET = False\n", encoding="utf-8") + assert runner.main(["evaluate", str(job)]) == 2 + + with pytest.raises(ValueError, match="escapes"): + runner.safe_relative_path(tmp_path, "../outside.py") + assert tmp_path.joinpath("secret.py").read_text(encoding="utf-8") == "SECRET = True\n" + + draft.joinpath("secret.py").unlink() + link = draft / "linked.py" + try: + link.symlink_to(tmp_path / "secret.py") + except OSError: + pytest.skip("symlinks are unavailable on this platform") + assert runner.main(["evaluate", str(job)]) == 2 + + +def test_candidate_rejects_stale_manifest_and_budget_drift(tmp_path, monkeypatch): + runner = _load_runner() + job, client, config = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + assert runner.main(["prepare", str(job), "--name", "stale", "--hypothesis", "change code"]) == 0 + candidate_dir = tmp_path / ".nvflare" / "autofl" / "candidates" / "stale" + candidate_dir.joinpath("source/client.py").write_text("ALGORITHM = 'candidate'\n", encoding="utf-8") + manifest_path = candidate_dir / "candidate_manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["base_source_sha256"] = "0" * 64 + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + assert runner.main(["evaluate", str(job)]) == 2 + + manifest["base_source_sha256"] = json.loads( + tmp_path.joinpath(".nvflare/autofl/campaign.json").read_text(encoding="utf-8") + )["best_source_sha256"] + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + drifted = deepcopy(config) + drifted["budget"]["fixed_training_budget"]["num_rounds"] = 2 + monkeypatch.setattr(runner, "import_job_config", lambda *args, **kwargs: deepcopy(drifted)) + assert runner.main(["evaluate", str(job)]) == 2 + assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" + + +def test_abandon_candidate_clears_pending_draft_without_touching_best(tmp_path, monkeypatch): + runner = _load_runner() + job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + assert runner.main(["prepare", str(job), "--name", "abandoned", "--hypothesis", "temporary idea"]) == 0 + draft = tmp_path / ".nvflare" / "autofl" / "candidates" / "abandoned" / "source" / "client.py" + draft.write_text("ALGORITHM = 'temporary'\n", encoding="utf-8") + + assert runner.main(["abandon", str(job)]) == 0 + assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" + manifest = json.loads( + tmp_path.joinpath(".nvflare/autofl/candidates/abandoned/candidate_manifest.json").read_text(encoding="utf-8") + ) + assert manifest["status"] == "abandoned" + state = json.loads(tmp_path.joinpath(".nvflare/autofl/campaign_state.json").read_text(encoding="utf-8")) + assert state["next_action"] == "propose_candidate" + assert state["pending_candidate_manifest"] is None + + +def test_initialize_retries_an_unscored_baseline(tmp_path, monkeypatch): + runner = _load_runner() + job = tmp_path / "job.py" + job.write_text("print('job')\n", encoding="utf-8") + tmp_path.joinpath("client.py").write_text("ALGORITHM = 'baseline'\n", encoding="utf-8") + monkeypatch.setattr(runner, "import_job_config", lambda *args, **kwargs: deepcopy(_campaign_config())) + monkeypatch.setattr(runner, "job_help", lambda *args, **kwargs: "") + scores = iter([None, 0.5]) + + def fake_run(run_def, **kwargs): + return runner.RunRecord( + "baseline", + "baseline", + next(scores), + 1.0, + "none", + "baseline", + "python job.py", + "/tmp/baseline", + ) + + monkeypatch.setattr(runner, "run_job", fake_run) + command = ["initialize", str(job), "--no-prefer-synthetic"] + assert runner.main(command) == 1 + assert runner.main(command) == 0 + records = runner.load_results(tmp_path / "results.tsv") + assert [(record.status, record.score) for record in records] == [("baseline", 0.5)] + + +def test_record_literature_checkpoint_returns_to_agent_proposal(tmp_path, monkeypatch): + runner = _load_runner() + job, _, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + + assert ( + runner.main( + [ + "record", + str(job), + "--literature", + "--hypothesis", + "reviewed adaptive federated optimization", + ] + ) + == 0 + ) + records = runner.load_results(tmp_path / "results.tsv") + assert records[-1].status == "literature" + assert records[-1].diff_summary == "reviewed adaptive federated optimization" + state = json.loads(tmp_path.joinpath(".nvflare/autofl/campaign_state.json").read_text(encoding="utf-8")) + assert state["next_action"] == "propose_candidate" + + +def test_external_candidate_uses_standard_job_result_recording(tmp_path, monkeypatch): + runner = _load_runner() + job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch, target_env="prod") + assert runner.main(["record", str(job), "--baseline", "--score", "0.5", "--job-id", "job-baseline"]) == 0 + assert runner.main(["prepare", str(job), "--name", "prod_algo", "--hypothesis", "production algorithm"]) == 0 + draft = tmp_path / ".nvflare" / "autofl" / "candidates" / "prod_algo" / "source" + draft.joinpath("client.py").write_text("ALGORITHM = 'production'\n", encoding="utf-8") + assert runner.main(["evaluate", str(job)]) == 0 + assert client.read_text(encoding="utf-8") == "ALGORITHM = 'production'\n" + + manifest_path = tmp_path / ".nvflare" / "autofl" / "candidates" / "prod_algo" / "candidate_manifest.json" + assert json.loads(manifest_path.read_text(encoding="utf-8"))["status"] == "ready_for_external_execution" + assert ( + runner.main( + [ + "record", + str(job), + "--manifest", + str(manifest_path), + "--score", + "0.8", + "--job-id", + "job-candidate", + ] + ) + == 0 + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assert manifest["status"] == "keep" + assert manifest["artifacts"]["job_id"] == "job-candidate" + + +def test_suggest_returns_fallbacks_without_executing_them(tmp_path, monkeypatch, capsys): + runner = _load_runner() + job, _, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + capsys.readouterr() + monkeypatch.setattr(runner, "job_help", lambda *args, **kwargs: "--lr") + monkeypatch.setattr( + runner, + "run_job", + lambda *args, **kwargs: pytest.fail("suggest must not execute a candidate"), + ) + + assert runner.main(["suggest", str(job), "--limit", "2"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert len(payload["suggestions"]) == 2 + assert all(item["run_args"] for item in payload["suggestions"]) + + +def test_import_job_config_forwards_minimization_mode(tmp_path, monkeypatch): + runner = _load_runner() + job = tmp_path / "job.py" + job.write_text("print('job')\n", encoding="utf-8") + output = tmp_path / "autofl.yaml" + captured = {} + + def fake_run(command, cwd, timeout, log_path): + captured["command"] = command + runner.write_yaml(output, _campaign_config()) + return 0, "", 0.0 + + monkeypatch.setattr(runner, "run", fake_run) + args = runner.parse_args(["initialize", str(job), "--mode", "min"]) + runner.import_job_config(args, job, output, tmp_path / "import.log", 10) + + mode_index = captured["command"].index("--mode") + assert captured["command"][mode_index + 1] == "min" + + +def test_cli_lifecycle_runs_agent_code_candidate_end_to_end(tmp_path): + repo_root = Path(__file__).parents[3] + runner_path = repo_root / "skills" / "nvflare-autofl" / "scripts" / "run_job_campaign.py" + job = tmp_path / "job.py" + job.write_text( + """ +import argparse +import json +from pathlib import Path + +SCORE = 0.5 + +class FedAvgRecipe: + def __init__(self, **kwargs): + self.kwargs = kwargs + +class SimEnv: + def __init__(self, **kwargs): + self.kwargs = kwargs + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--name", default="run") + parser.add_argument("--num_rounds", type=int, default=1) + parser.add_argument("--n_clients", type=int, default=2) + args = parser.parse_args() + FedAvgRecipe(model=object(), num_rounds=args.num_rounds, min_clients=args.n_clients) + SimEnv(num_clients=args.n_clients) + result = Path(f"result-{args.name}") + result.mkdir(exist_ok=True) + result.joinpath("metrics_summary.json").write_text(json.dumps({"accuracy": SCORE})) + print(f"Result can be found in : {result.resolve()}") + +if __name__ == "__main__": + main() +""".lstrip(), + encoding="utf-8", + ) + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join(filter(None, [str(repo_root), env.get("PYTHONPATH")])) + + subprocess.run( + [ + sys.executable, + str(runner_path), + "initialize", + str(job), + "--metric", + "accuracy", + "--no-prefer-synthetic", + ], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + [ + sys.executable, + str(runner_path), + "prepare", + str(job), + "--name", + "code_candidate", + "--hypothesis", + "raise the reported score through a source change", + ], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) + draft_job = tmp_path / ".nvflare" / "autofl" / "candidates" / "code_candidate" / "source" / "job.py" + draft_job.write_text(draft_job.read_text(encoding="utf-8").replace("SCORE = 0.5", "SCORE = 0.8"), encoding="utf-8") + subprocess.run( + [sys.executable, str(runner_path), "evaluate", str(job)], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) + + runner = _load_runner() + records = runner.load_results(tmp_path / "results.tsv") + assert [(record.status, record.score) for record in records] == [("baseline", 0.5), ("keep", 0.8)] + assert "SCORE = 0.8" in job.read_text(encoding="utf-8") + manifest = json.loads( + tmp_path.joinpath(".nvflare/autofl/candidates/code_candidate/candidate_manifest.json").read_text( + encoding="utf-8" + ) + ) + assert manifest["status"] == "keep" + assert manifest["changed_files"] == ["job.py"] From 28f1a9f50010b018cf104f0ab6452779f264aba4 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Mon, 29 Jun 2026 12:55:28 -0400 Subject: [PATCH 17/23] Restore Auto-FL workspace on schema errors Signed-off-by: Holger Roth --- skills/nvflare-autofl/scripts/run_job_campaign.py | 4 ++-- tests/unit_test/tool/autofl_skill_runner_test.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index b77f650c8e..10e31847eb 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -2127,10 +2127,10 @@ def evaluate_candidate(args: argparse.Namespace, job: Path) -> int: } ) write_json(manifest_path, manifest) - apply_candidate_source(workspace, manifest_path.parent / "source", changed) - schema = load_mutation_schema(workspace) timeout, no_progress_timeout = campaign_timeout(args, schema) + apply_candidate_source(workspace, manifest_path.parent / "source", changed) + candidate_config_path = manifest_path.parent / "candidate_autofl.yaml" try: candidate_config = import_job_config( diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index 816b6b48ae..f235fbf069 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -484,6 +484,21 @@ def test_candidate_rejects_stale_manifest_and_budget_drift(tmp_path, monkeypatch assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" +def test_candidate_schema_failure_does_not_modify_workspace(tmp_path, monkeypatch): + runner = _load_runner() + job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + assert runner.main(["prepare", str(job), "--name", "bad_schema", "--hypothesis", "change code"]) == 0 + candidate_dir = tmp_path / ".nvflare" / "autofl" / "candidates" / "bad_schema" + candidate_dir.joinpath("source/client.py").write_text("ALGORITHM = 'candidate'\n", encoding="utf-8") + tmp_path.joinpath("mutation_schema.yaml").write_text( + "comparison_budget_args:\n default_candidate_budget:\n run_timeout_seconds: fast\n", + encoding="utf-8", + ) + + assert runner.main(["evaluate", str(job)]) == 2 + assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" + + def test_abandon_candidate_clears_pending_draft_without_touching_best(tmp_path, monkeypatch): runner = _load_runner() job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) From 0020b3965252d275d3edbe0a4bd5117ac598c0fe Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Mon, 29 Jun 2026 13:20:22 -0400 Subject: [PATCH 18/23] Harden Auto-FL candidate error recovery Signed-off-by: Holger Roth --- .../scripts/run_job_campaign.py | 17 ++++--- .../tool/autofl_skill_runner_test.py | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index 10e31847eb..938bc34ab8 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -500,8 +500,14 @@ def run_allow_timeout( def read_yaml(path: Path) -> Dict[str, Any]: if yaml is None: - raise RuntimeError("PyYAML is required to read autofl.yaml") - return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + raise RuntimeError("PyYAML is required to read YAML files") + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + raise ValueError(f"invalid YAML in {path}: {e}") from e + if not isinstance(data, dict): + raise ValueError(f"invalid YAML in {path}: expected a mapping") + return data def write_yaml(path: Path, data: Dict[str, Any]) -> None: @@ -1021,9 +1027,7 @@ def load_mutation_schema(cwd: Path) -> Dict[str, Any]: path = cwd / "mutation_schema.yaml" if not path.exists(): return {} - if yaml is None: - raise RuntimeError("PyYAML is required to read mutation_schema.yaml") - return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return read_yaml(path) def comparison_budget(schema: Dict[str, Any]) -> Dict[str, Any]: @@ -2129,10 +2133,9 @@ def evaluate_candidate(args: argparse.Namespace, job: Path) -> int: write_json(manifest_path, manifest) schema = load_mutation_schema(workspace) timeout, no_progress_timeout = campaign_timeout(args, schema) - apply_candidate_source(workspace, manifest_path.parent / "source", changed) - candidate_config_path = manifest_path.parent / "candidate_autofl.yaml" try: + apply_candidate_source(workspace, manifest_path.parent / "source", changed) candidate_config = import_job_config( args, job, diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index f235fbf069..57c02add40 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -499,6 +499,51 @@ def test_candidate_schema_failure_does_not_modify_workspace(tmp_path, monkeypatc assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" +def test_candidate_partial_apply_failure_restores_workspace(tmp_path, monkeypatch): + runner = _load_runner() + job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + baseline_job = job.read_text(encoding="utf-8") + baseline_client = client.read_text(encoding="utf-8") + assert runner.main(["prepare", str(job), "--name", "partial", "--hypothesis", "change two files"]) == 0 + draft = tmp_path / ".nvflare" / "autofl" / "candidates" / "partial" / "source" + draft.joinpath("client.py").write_text("ALGORITHM = 'candidate'\n", encoding="utf-8") + draft.joinpath("job.py").write_text("print('candidate')\n", encoding="utf-8") + original_copy = runner.copy_relative_file + + def fail_second_candidate_copy(source_root, destination_root, relative): + if source_root == draft and relative == "job.py": + raise OSError("simulated candidate copy failure") + original_copy(source_root, destination_root, relative) + + monkeypatch.setattr(runner, "copy_relative_file", fail_second_candidate_copy) + + assert runner.main(["evaluate", str(job)]) == 2 + assert job.read_text(encoding="utf-8") == baseline_job + assert client.read_text(encoding="utf-8") == baseline_client + + +def test_malformed_yaml_returns_clean_cli_errors(tmp_path, monkeypatch, capsys): + runner = _load_runner() + job, _, config = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + capsys.readouterr() + autofl_yaml = tmp_path / "autofl.yaml" + autofl_yaml.write_text("job: [\n", encoding="utf-8") + + assert runner.main(["suggest", str(job)]) == 2 + stderr = capsys.readouterr().err + assert f"Auto-FL suggest failed: invalid YAML in {autofl_yaml}" in stderr + assert "Traceback" not in stderr + + runner.write_yaml(autofl_yaml, config) + mutation_schema = tmp_path / "mutation_schema.yaml" + mutation_schema.write_text("comparison_budget_args: [\n", encoding="utf-8") + + assert runner.main(["suggest", str(job)]) == 2 + stderr = capsys.readouterr().err + assert f"Auto-FL suggest failed: invalid YAML in {mutation_schema}" in stderr + assert "Traceback" not in stderr + + def test_abandon_candidate_clears_pending_draft_without_touching_best(tmp_path, monkeypatch): runner = _load_runner() job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) From ebc0c05eef5d4ead9843c647da04b19b9e92154e Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Mon, 29 Jun 2026 15:01:14 -0400 Subject: [PATCH 19/23] Make Auto-FL candidate finalization transactional Signed-off-by: Holger Roth --- .../scripts/run_job_campaign.py | 220 +++++++++++++----- .../tool/autofl_skill_runner_test.py | 110 +++++++++ 2 files changed, 273 insertions(+), 57 deletions(-) diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index 938bc34ab8..a272337481 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -37,6 +37,7 @@ import subprocess import sys import time +import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path @@ -521,6 +522,28 @@ def write_json(path: Path, data: Dict[str, Any]) -> None: path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") +def atomic_write_bytes(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp-{uuid.uuid4().hex}") + try: + temporary.write_bytes(data) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def capture_file_versions(paths: Iterable[Path]) -> Dict[Path, Optional[bytes]]: + return {path: path.read_bytes() if path.is_file() else None for path in paths} + + +def restore_file_versions(versions: Dict[Path, Optional[bytes]]) -> None: + for path, data in versions.items(): + if data is None: + path.unlink(missing_ok=True) + else: + atomic_write_bytes(path, data) + + def read_json(path: Path) -> Dict[str, Any]: try: payload = json.loads(path.read_text(encoding="utf-8")) @@ -591,19 +614,65 @@ def copy_relative_file(source_root: Path, destination_root: Path, relative: str) shutil.copy2(source, destination) -def create_best_snapshot(workspace: Path, config: Dict[str, Any], snapshot_root: Path) -> Dict[str, str]: - if snapshot_root.exists(): - shutil.rmtree(snapshot_root) - source_root = snapshot_root / "source" +def stage_best_snapshot(workspace: Path, config: Dict[str, Any], snapshot_root: Path) -> Tuple[Path, Dict[str, str]]: + snapshot_root.parent.mkdir(parents=True, exist_ok=True) + staged_root = snapshot_root.parent / f".{snapshot_root.name}.staged-{uuid.uuid4().hex}" + source_root = staged_root / "source" source_root.mkdir(parents=True, exist_ok=True) - for relative in allowed_edit_paths(config, workspace): - source = workspace / relative - if source.is_symlink(): - raise ValueError(f"allowed edit path is a symlink: {source}") - if source.is_file(): - copy_relative_file(workspace, source_root, relative) - files = file_map(source_root) - write_json(snapshot_root / "snapshot.json", {"source_sha256": source_hash(files), "files": files}) + try: + for relative in allowed_edit_paths(config, workspace): + source = workspace / relative + if source.is_symlink(): + raise ValueError(f"allowed edit path is a symlink: {source}") + if source.is_file(): + copy_relative_file(workspace, source_root, relative) + files = file_map(source_root) + write_json(staged_root / "snapshot.json", {"source_sha256": source_hash(files), "files": files}) + return staged_root, files + except BaseException: + shutil.rmtree(staged_root, ignore_errors=True) + raise + + +def activate_best_snapshot(snapshot_root: Path, staged_root: Path) -> Optional[Path]: + previous_root = None + if snapshot_root.exists(): + previous_root = snapshot_root.parent / f".{snapshot_root.name}.previous-{uuid.uuid4().hex}" + os.replace(snapshot_root, previous_root) + try: + os.replace(staged_root, snapshot_root) + except BaseException: + if previous_root is not None: + os.replace(previous_root, snapshot_root) + raise + return previous_root + + +def rollback_best_snapshot(snapshot_root: Path, previous_root: Optional[Path]) -> None: + if previous_root is None: + shutil.rmtree(snapshot_root, ignore_errors=True) + return + failed_root = snapshot_root.parent / f".{snapshot_root.name}.failed-{uuid.uuid4().hex}" + if snapshot_root.exists(): + os.replace(snapshot_root, failed_root) + try: + os.replace(previous_root, snapshot_root) + except BaseException: + if failed_root.exists(): + os.replace(failed_root, snapshot_root) + raise + shutil.rmtree(failed_root, ignore_errors=True) + + +def create_best_snapshot(workspace: Path, config: Dict[str, Any], snapshot_root: Path) -> Dict[str, str]: + staged_root, files = stage_best_snapshot(workspace, config, snapshot_root) + try: + previous_root = activate_best_snapshot(snapshot_root, staged_root) + finally: + if staged_root.exists(): + shutil.rmtree(staged_root, ignore_errors=True) + if previous_root is not None: + shutil.rmtree(previous_root, ignore_errors=True) return files @@ -2052,56 +2121,93 @@ def finalize_candidate_result( patch: str, record: RunRecord, ) -> Tuple[List[RunRecord], Dict[str, Any]]: - records = load_results(paths["results"]) - previous_best = best_retained_record(records, args.mode) - if record.status == "candidate": - record.status = ( - "keep" if better(record.score, previous_best.score if previous_best else None, args.mode) else "discard" + rollback_files: Dict[Path, Optional[bytes]] = {} + staged_snapshot = None + previous_snapshot = None + try: + records = load_results(paths["results"]) + previous_best = best_retained_record(records, args.mode) + if record.status == "candidate": + record.status = ( + "keep" if better(record.score, previous_best.score if previous_best else None, args.mode) else "discard" + ) + patch_path = manifest_path.parent / "candidate.patch" + rollback_files = capture_file_versions( + [ + paths["autofl_yaml"], + manifest_path, + campaign_metadata_path(job.parent), + paths["results"], + paths["state"], + paths["progress"], + paths["report"], + patch_path, + ] ) - patch_path = manifest_path.parent / "candidate.patch" - patch_path.write_text(patch, encoding="utf-8") - patch_sha256 = sha256_bytes(patch.encode("utf-8")) - record.changed_files = ",".join(changed) if changed else "none" - record.diff_summary = str(manifest.get("hypothesis") or "candidate") - record.candidate_manifest = str(manifest_path.resolve()) - record.base_candidate = str(manifest.get("base_candidate") or "") - record.patch_sha256 = patch_sha256 - - if record.status == "keep": - update_config_for_kept_sources(config, created) - write_yaml(paths["autofl_yaml"], config) - snapshot_files = create_best_snapshot(job.parent, config, paths["snapshot_root"]) - metadata.update( + patch_path.write_text(patch, encoding="utf-8") + patch_sha256 = sha256_bytes(patch.encode("utf-8")) + record.changed_files = ",".join(changed) if changed else "none" + record.diff_summary = str(manifest.get("hypothesis") or "candidate") + record.candidate_manifest = str(manifest_path.resolve()) + record.base_candidate = str(manifest.get("base_candidate") or "") + record.patch_sha256 = patch_sha256 + + if record.status == "keep": + update_config_for_kept_sources(config, created) + staged_snapshot, snapshot_files = stage_best_snapshot(job.parent, config, paths["snapshot_root"]) + write_yaml(paths["autofl_yaml"], config) + previous_snapshot = activate_best_snapshot(paths["snapshot_root"], staged_snapshot) + staged_snapshot = None + metadata.update( + { + "best_candidate": record.name, + "best_score": record.score, + "best_source_sha256": source_hash(snapshot_files), + "updated_at": utc_now(), + } + ) + else: + restore_best_source(job.parent, best_source, best_files, changed) + + manifest.update( { - "best_candidate": record.name, - "best_score": record.score, - "best_source_sha256": source_hash(snapshot_files), + "status": record.status, "updated_at": utc_now(), + "changed_files": changed, + "created_files": created, + "patch_sha256": patch_sha256, + "artifacts": {"patch": str(patch_path.resolve()), "run": record.artifacts}, + "result": { + "score": record.score, + "runtime_seconds": record.runtime_seconds, + "run_command": record.run_command, + "failure_reason": record.failure_reason, + }, } ) - else: - restore_best_source(job.parent, best_source, best_files, changed) + write_json(manifest_path, manifest) + write_json(campaign_metadata_path(job.parent), metadata) + records.append(record) + state = refresh_campaign_artifacts(args, paths, config, records, metadata) + except BaseException as error: + try: + if previous_snapshot is not None: + rollback_best_snapshot(paths["snapshot_root"], previous_snapshot) + previous_snapshot = None + restore_best_source(job.parent, paths["snapshot_root"] / "source", best_files, changed) + if rollback_files: + restore_file_versions(rollback_files) + except BaseException as rollback_error: + raise RuntimeError( + f"candidate finalization failed ({error}); automatic workspace rollback also failed ({rollback_error})" + ) from rollback_error + raise + finally: + if staged_snapshot is not None: + shutil.rmtree(staged_snapshot, ignore_errors=True) - manifest.update( - { - "status": record.status, - "updated_at": utc_now(), - "changed_files": changed, - "created_files": created, - "patch_sha256": patch_sha256, - "artifacts": {"patch": str(patch_path.resolve()), "run": record.artifacts}, - "result": { - "score": record.score, - "runtime_seconds": record.runtime_seconds, - "run_command": record.run_command, - "failure_reason": record.failure_reason, - }, - } - ) - write_json(manifest_path, manifest) - write_json(campaign_metadata_path(job.parent), metadata) - records.append(record) - state = refresh_campaign_artifacts(args, paths, config, records, metadata) + if previous_snapshot is not None: + shutil.rmtree(previous_snapshot, ignore_errors=True) return records, state @@ -2174,8 +2280,8 @@ def evaluate_candidate(args: argparse.Namespace, job: Path) -> int: ) return 0 - help_text = job_help(args.python, job, workspace) try: + help_text = job_help(args.python, job, workspace) run_record = run_job( JobRun( name=str(manifest["candidate_id"]), diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index 57c02add40..3a06958d38 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -522,6 +522,116 @@ def fail_second_candidate_copy(source_root, destination_root, relative): assert client.read_text(encoding="utf-8") == baseline_client +def test_candidate_job_help_failure_restores_workspace(tmp_path, monkeypatch): + runner = _load_runner() + job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + assert runner.main(["prepare", str(job), "--name", "help_failure", "--hypothesis", "change code"]) == 0 + draft = tmp_path / ".nvflare" / "autofl" / "candidates" / "help_failure" / "source" + draft.joinpath("client.py").write_text("ALGORITHM = 'candidate'\n", encoding="utf-8") + + def fail_job_help(*args, **kwargs): + raise OSError("simulated missing Python executable") + + monkeypatch.setattr(runner, "job_help", fail_job_help) + + assert runner.main(["evaluate", str(job)]) == 2 + assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" + + +def test_candidate_finalization_failure_rolls_back_workspace_and_campaign_files(tmp_path, monkeypatch): + runner = _load_runner() + job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + assert runner.main(["prepare", str(job), "--name", "late_failure", "--hypothesis", "improve code"]) == 0 + candidate_dir = tmp_path / ".nvflare" / "autofl" / "candidates" / "late_failure" + candidate_dir.joinpath("source/client.py").write_text("ALGORITHM = 'candidate'\n", encoding="utf-8") + original_autofl = tmp_path.joinpath("autofl.yaml").read_bytes() + original_results = tmp_path.joinpath("results.tsv").read_bytes() + original_state = tmp_path.joinpath(".nvflare/autofl/campaign_state.json").read_bytes() + original_progress = tmp_path.joinpath("progress.png").read_bytes() + original_report = tmp_path.joinpath("autofl_report.md").read_bytes() + + def improved_run(run_def, **kwargs): + return runner.RunRecord( + "candidate", run_def.name, 0.7, 2.0, "none", run_def.description, "python job.py", "/tmp/late_failure" + ) + + original_refresh = runner.refresh_campaign_artifacts + + def fail_artifact_refresh(*args, **kwargs): + original_refresh(*args, **kwargs) + raise OSError("simulated report write failure") + + monkeypatch.setattr(runner, "run_job", improved_run) + monkeypatch.setattr(runner, "refresh_campaign_artifacts", fail_artifact_refresh) + + assert runner.main(["evaluate", str(job)]) == 2 + assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" + assert tmp_path.joinpath("autofl.yaml").read_bytes() == original_autofl + assert tmp_path.joinpath("results.tsv").read_bytes() == original_results + assert tmp_path.joinpath(".nvflare/autofl/campaign_state.json").read_bytes() == original_state + assert tmp_path.joinpath("progress.png").read_bytes() == original_progress + assert tmp_path.joinpath("autofl_report.md").read_bytes() == original_report + best_source, best_files = runner.load_best_snapshot(tmp_path / ".nvflare" / "autofl" / "snapshots" / "best") + assert runner.workspace_matches_snapshot(tmp_path, best_source, best_files) + metadata = json.loads(tmp_path.joinpath(".nvflare/autofl/campaign.json").read_text(encoding="utf-8")) + assert metadata["best_candidate"] == "baseline" + manifest = json.loads(candidate_dir.joinpath("candidate_manifest.json").read_text(encoding="utf-8")) + assert manifest["status"] == "prepared" + + +def test_candidate_snapshot_stage_failure_preserves_previous_best(tmp_path, monkeypatch): + runner = _load_runner() + job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + assert runner.main(["prepare", str(job), "--name", "snapshot_failure", "--hypothesis", "improve code"]) == 0 + draft = tmp_path / ".nvflare" / "autofl" / "candidates" / "snapshot_failure" / "source" + draft.joinpath("client.py").write_text("ALGORITHM = 'candidate'\n", encoding="utf-8") + + def improved_run(run_def, **kwargs): + return runner.RunRecord( + "candidate", run_def.name, 0.7, 2.0, "none", run_def.description, "python job.py", "/tmp/snapshot" + ) + + def fail_snapshot_stage(*args, **kwargs): + raise OSError("simulated snapshot copy failure") + + monkeypatch.setattr(runner, "run_job", improved_run) + monkeypatch.setattr(runner, "stage_best_snapshot", fail_snapshot_stage) + + assert runner.main(["evaluate", str(job)]) == 2 + assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" + best_source, best_files = runner.load_best_snapshot(tmp_path / ".nvflare" / "autofl" / "snapshots" / "best") + assert runner.workspace_matches_snapshot(tmp_path, best_source, best_files) + + +def test_candidate_discard_restore_failure_retries_rollback(tmp_path, monkeypatch): + runner = _load_runner() + job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + assert runner.main(["prepare", str(job), "--name", "discard_failure", "--hypothesis", "regress code"]) == 0 + draft = tmp_path / ".nvflare" / "autofl" / "candidates" / "discard_failure" / "source" + draft.joinpath("client.py").write_text("ALGORITHM = 'candidate'\n", encoding="utf-8") + original_restore = runner.restore_best_source + restore_calls = 0 + + def fail_first_restore(*args, **kwargs): + nonlocal restore_calls + restore_calls += 1 + if restore_calls == 1: + raise OSError("simulated restore failure") + original_restore(*args, **kwargs) + + def regressed_run(run_def, **kwargs): + return runner.RunRecord( + "candidate", run_def.name, 0.3, 2.0, "none", run_def.description, "python job.py", "/tmp/discard_failure" + ) + + monkeypatch.setattr(runner, "restore_best_source", fail_first_restore) + monkeypatch.setattr(runner, "run_job", regressed_run) + + assert runner.main(["evaluate", str(job)]) == 2 + assert restore_calls == 2 + assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" + + def test_malformed_yaml_returns_clean_cli_errors(tmp_path, monkeypatch, capsys): runner = _load_runner() job, _, config = _initialize_fake_campaign(runner, tmp_path, monkeypatch) From 6a3cd2723b33a2376bc81b00c77668de76c68474 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Mon, 29 Jun 2026 21:46:14 -0400 Subject: [PATCH 20/23] Improve Auto-FL campaign progress plots --- .../nvflare-autofl/scripts/plot_progress.py | 537 ++++++++++++++++++ .../scripts/run_job_campaign.py | 21 +- .../tool/autofl_skill_plot_progress_test.py | 127 +++++ .../tool/autofl_skill_runner_test.py | 1 + 4 files changed, 685 insertions(+), 1 deletion(-) create mode 100755 skills/nvflare-autofl/scripts/plot_progress.py create mode 100644 tests/unit_test/tool/autofl_skill_plot_progress_test.py diff --git a/skills/nvflare-autofl/scripts/plot_progress.py b/skills/nvflare-autofl/scripts/plot_progress.py new file mode 100755 index 0000000000..764d7b1be2 --- /dev/null +++ b/skills/nvflare-autofl/scripts/plot_progress.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generate a readable Auto-FL progress plot from the campaign ledger.""" + +from __future__ import annotations + +import argparse +import csv +import math +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, List, Optional, Sequence, Tuple + + +class PlotDependencyError(RuntimeError): + """Raised when the rich plotting dependency is unavailable.""" + + +class NoScoredResultsError(ValueError): + """Raised when a campaign has no scored rows to plot yet.""" + + +@dataclass +class ProgressRecord: + index: int + status: str + name: str + score: Optional[float] + runtime_seconds: float + description: str + + +def parse_score(value: Any) -> Optional[float]: + try: + score = float(value) + except (TypeError, ValueError): + return None + if math.isnan(score) or math.isinf(score): + return None + return score + + +def parse_runtime(value: Any) -> float: + try: + seconds = float(value) + except (TypeError, ValueError): + return 0.0 + if math.isnan(seconds) or math.isinf(seconds): + return 0.0 + return max(0.0, seconds) + + +def format_runtime(seconds: float) -> str: + if seconds <= 0: + return "" + if seconds < 3600: + return f"{seconds / 60:.1f}m" + return f"{seconds / 3600:.2f}h" + + +def truncate(text: Any, limit: int) -> str: + compact = " ".join(str(text or "").strip().split()) + if len(compact) <= limit: + return compact + return compact[: max(0, limit - 3)] + "..." + + +def compact_label(text: Any, limit: int) -> str: + compact = " ".join(str(text or "").strip().split()) + replacements = { + "server_momentum": "smom", + "server_lr": "slr", + "clip_grad_norm": "clip", + "eta_min_factor": "eta_min", + "label_smoothing": "ls", + } + for old, new in replacements.items(): + compact = compact.replace(old, new) + for marker in [" (", ";"]: + if marker in compact: + compact = compact.split(marker, 1)[0] + return truncate(compact, limit) + + +def record_label(record: ProgressRecord, limit: int) -> str: + return compact_label(record.name or record.description, limit) + + +def load_results(path: Path) -> List[ProgressRecord]: + records = [] + with path.open("r", encoding="utf-8", newline="") as f: + for index, row in enumerate(csv.DictReader(f, delimiter="\t")): + records.append( + ProgressRecord( + index=index, + status=(row.get("status", "") or "").strip().lower(), + name=row.get("name", "") or row.get("candidate", "") or row.get("commit", ""), + score=parse_score(row.get("score", "")), + runtime_seconds=parse_runtime(row.get("runtime_seconds", "")), + description=row.get("diff_summary", "") or row.get("description", ""), + ) + ) + return records + + +def normalize_records(records: Iterable[Any]) -> List[ProgressRecord]: + normalized = [] + for index, record in enumerate(records): + normalized.append( + ProgressRecord( + index=index, + status=str(getattr(record, "status", "") or "").strip().lower(), + name=str(getattr(record, "name", "") or ""), + score=parse_score(getattr(record, "score", None)), + runtime_seconds=parse_runtime(getattr(record, "runtime_seconds", 0.0)), + description=str(getattr(record, "diff_summary", "") or getattr(record, "description", "") or ""), + ) + ) + return normalized + + +def better(value: float, incumbent: Optional[float], mode: str) -> bool: + if incumbent is None: + return True + return value > incumbent if mode == "max" else value < incumbent + + +def cumulative_best(values: Sequence[float], mode: str) -> List[float]: + incumbent = None + result = [] + for value in values: + if better(value, incumbent, mode): + incumbent = value + result.append(incumbent) + return result + + +def percentile(values: Sequence[float], fraction: float) -> float: + if not values: + raise ValueError("percentile requires at least one value") + if len(values) == 1: + return values[0] + ordered = sorted(values) + position = min(max(fraction, 0.0), 1.0) * (len(ordered) - 1) + lower_index = math.floor(position) + upper_index = math.ceil(position) + if lower_index == upper_index: + return ordered[lower_index] + weight = position - lower_index + return ordered[lower_index] + (ordered[upper_index] - ordered[lower_index]) * weight + + +def default_y_limits( + scores: Sequence[float], baseline: float, mode: str, full_y_range: bool = False +) -> Tuple[float, float]: + score_min = min(scores) + score_max = max(scores) + if full_y_range: + span = max(score_max - score_min, 0.01) + return score_min - max(0.01, span * 0.08), score_max + max(0.01, span * 0.16) + + if mode == "max": + useful_min = min(baseline, percentile(scores, 0.20)) + useful_span = max(score_max - useful_min, 0.01) + lower = useful_min - max(0.01, useful_span * 0.20) + upper = score_max + max(0.015, useful_span * 0.35) + if score_min >= lower: + full_span = max(score_max - score_min, 0.01) + lower = score_min - max(0.01, full_span * 0.08) + return lower, upper + + useful_max = max(baseline, percentile(scores, 0.80)) + useful_span = max(useful_max - score_min, 0.01) + lower = score_min - max(0.015, useful_span * 0.35) + upper = useful_max + max(0.01, useful_span * 0.20) + if score_max <= upper: + full_span = max(score_max - score_min, 0.01) + upper = score_max + max(0.01, full_span * 0.08) + return lower, upper + + +def select_observed_milestones( + valid: Sequence[ProgressRecord], mode: str, max_labels: int +) -> List[Tuple[float, ProgressRecord]]: + if max_labels <= 0: + return [] + incumbent = None + milestones = [] + final_running_best = None + for record in valid: + if record.score is None or not better(record.score, incumbent, mode): + continue + delta = 0.0 if incumbent is None else abs(record.score - incumbent) + final_running_best = (delta, record) + if incumbent is not None: + milestones.append((delta, record)) + incumbent = record.score + + if final_running_best is None: + return [] + if not milestones: + return [final_running_best] + + final_index = final_running_best[1].index + non_final = [item for item in milestones if item[1].index != final_index] + selected = sorted(non_final, key=lambda item: item[0], reverse=True)[: max_labels - 1] + selected_indices = {record.index for _, record in selected} + selected_indices.add(final_index) + return [(delta, record) for delta, record in milestones if record.index in selected_indices] + + +def select_literature_labels(records: Sequence[ProgressRecord], max_labels: int) -> List[ProgressRecord]: + if max_labels <= 0 or not records: + return [] + if len(records) <= max_labels: + return list(records) + latest = records[-1] + selected = {latest.index} + for record in sorted(records, key=lambda item: item.runtime_seconds, reverse=True): + if len(selected) >= max_labels: + break + selected.add(record.index) + return [record for record in records if record.index in selected] + + +def label_placement( + label_number: int, + record: ProgressRecord, + x_limits: Tuple[float, float], + y_limits: Tuple[float, float], +) -> Tuple[Tuple[int, int], str, str]: + x_span = max(x_limits[1] - x_limits[0], 1.0) + y_span = max(y_limits[1] - y_limits[0], 1e-9) + x_fraction = (record.index - x_limits[0]) / x_span + y_fraction = ((record.score or y_limits[0]) - y_limits[0]) / y_span + near_right = x_fraction > 0.72 + near_top = y_fraction > 0.78 + x_offset = -10 if near_right else 10 + y_base = -12 if near_top else 12 + y_step = (label_number % 3) * 8 + return ( + (x_offset, y_base - y_step if near_top else y_base + y_step), + "right" if near_right else "left", + "top" if near_top else "bottom", + ) + + +def plot_progress( + records: Iterable[Any], + output: Path, + mode: str, + metric_label: str, + max_labels: int = 6, + max_literature_labels: int = 4, + full_y_range: bool = False, +) -> Tuple[float, float]: + try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + except ImportError as exc: + raise PlotDependencyError("matplotlib is required for the rich Auto-FL progress plot") from exc + + rows = normalize_records(records) + valid = [record for record in rows if record.score is not None and record.status != "crash"] + literature_rows = [record for record in rows if record.status == "literature"] + if not valid: + raise NoScoredResultsError("No non-crash rows with numeric scores found") + + baseline_row = next((record for record in valid if record.status == "baseline"), valid[0]) + baseline = baseline_row.score + best_row = valid[0] + for record in valid[1:]: + if better(record.score, best_row.score, mode): + best_row = record + best_score = best_row.score + + fig, ax = plt.subplots(figsize=(16, 8)) + fig.patch.set_facecolor("white") + ax.set_facecolor("white") + styles = { + "discard": {"color": "#cccccc", "size": 18, "alpha": 0.55, "label": "Discarded"}, + "candidate": {"color": "#3498db", "size": 32, "alpha": 0.75, "label": "Candidate"}, + "keep": {"color": "#2ecc71", "size": 72, "alpha": 0.95, "label": "Kept"}, + } + groups = { + "discard": [record for record in valid if record.status == "discard"], + "candidate": [record for record in valid if record.status == "candidate"], + "keep": [record for record in valid if record.status in {"baseline", "keep"}], + } + for status, group in groups.items(): + if not group: + continue + style = styles[status] + ax.scatter( + [record.index for record in group], + [record.score for record in group], + c=style["color"], + s=style["size"], + alpha=style["alpha"], + zorder=4 if status == "keep" else 2, + label=style["label"], + edgecolors="black" if status == "keep" else "none", + linewidths=0.5 if status == "keep" else 0, + ) + + known_statuses = {"baseline", "keep", "discard", "candidate", "crash", "literature"} + other = [record for record in valid if record.status not in known_statuses] + if other: + ax.scatter( + [record.index for record in other], + [record.score for record in other], + c="#9b59b6", + s=28, + alpha=0.65, + zorder=2, + label="Other", + ) + + observed_scores = [record.score for record in valid] + ax.step( + [record.index for record in valid], + cumulative_best(observed_scores, mode), + where="post", + color="#27ae60", + linewidth=2.2, + alpha=0.75, + zorder=3, + label="Running best observed", + ) + + runtime_rows = [record for record in rows if record.runtime_seconds > 0 and record.status != "literature"] + total_runtime = sum(record.runtime_seconds for record in runtime_rows) + average_runtime = total_runtime / len(runtime_rows) if runtime_rows else 0.0 + literature_runtime = sum(record.runtime_seconds for record in literature_rows) + runtime_title = f", {format_runtime(total_runtime)} total" if total_runtime else "" + if average_runtime: + runtime_title += f", {format_runtime(average_runtime)} avg/candidate" + if literature_rows: + runtime_title += f", {len(literature_rows)} lit" + if literature_runtime: + runtime_title += f" ({format_runtime(literature_runtime)})" + + direction = "higher" if mode == "max" else "lower" + ax.set_xlabel("Experiment #", fontsize=12) + ax.set_ylabel(f"{metric_label} ({direction} is better)", fontsize=12) + ax.set_title( + f"Auto-FL Progress ({metric_label}): {len(rows)} rows, {len(valid)} scored, " + f"{sum(record.status == 'keep' for record in rows)} kept, " + f"{sum(record.status == 'candidate' for record in rows)} candidate, " + f"{sum(record.status == 'discard' for record in rows)} discarded, " + f"{sum(record.status == 'crash' for record in rows)} crash{runtime_title}", + fontsize=14, + ) + ax.axhline( + baseline, + color="#7f8c8d", + linewidth=1.2, + alpha=0.5, + linestyle="--", + label="Baseline", + ) + ax.grid(True, alpha=0.2) + + y_limits = default_y_limits(observed_scores, baseline, mode, full_y_range=full_y_range) + ax.set_ylim(*y_limits) + ax.set_xlim(-0.5, max(len(rows) - 0.5, max(record.index for record in valid) + 0.5)) + + if literature_rows: + event_color = "#8e44ad" + event_y = y_limits[1] - max(y_limits[1] - y_limits[0], 1e-9) * 0.035 + for record in literature_rows: + ax.axvline(record.index, color=event_color, linestyle=":", linewidth=1.1, alpha=0.45, zorder=1) + ax.scatter( + [record.index for record in literature_rows], + [event_y for _ in literature_rows], + marker="v", + c=event_color, + s=50, + alpha=0.85, + zorder=5, + label="Literature review", + edgecolors="white", + linewidths=0.4, + ) + event_x_limits = ax.get_xlim() + event_x_span = max(event_x_limits[1] - event_x_limits[0], 1.0) + for record in select_literature_labels(literature_rows, max_literature_labels): + runtime = format_runtime(record.runtime_seconds) + near_right = (record.index - event_x_limits[0]) / event_x_span > 0.88 + annotation = ax.annotate( + f"lit #{record.index}{f' {runtime}' if runtime else ''}: {compact_label(record.description, 30)}", + (record.index, event_y), + textcoords="offset points", + xytext=(-4, -4) if near_right else (4, -4), + fontsize=7.0, + color=event_color, + alpha=0.9, + rotation=90, + ha="right" if near_right else "left", + va="top", + annotation_clip=True, + ) + annotation.set_clip_on(True) + + for label_number, (_, record) in enumerate(select_observed_milestones(valid, mode, max_labels)): + offset, horizontal_align, vertical_align = label_placement(label_number, record, ax.get_xlim(), ax.get_ylim()) + annotation = ax.annotate( + f"#{record.index} {record.score:.4f}: {record_label(record, 28)}", + (record.index, record.score), + textcoords="offset points", + xytext=offset, + fontsize=8.0, + color="#1a7a3a", + alpha=0.9, + ha=horizontal_align, + va=vertical_align, + annotation_clip=True, + arrowprops={ + "arrowstyle": "-", + "color": "#1a7a3a", + "alpha": 0.35, + "linewidth": 0.8, + "shrinkA": 0, + "shrinkB": 4, + }, + ) + annotation.set_clip_on(True) + + summary_lines = [ + f"Baseline: {baseline:.6f}", + f"Best: {best_score:.6f}", + f"Delta: {best_score - baseline:+.6f}", + ] + if total_runtime: + summary_lines.append(f"Runtime: {format_runtime(total_runtime)}") + if average_runtime: + summary_lines.append(f"Avg/candidate: {format_runtime(average_runtime)}") + if literature_rows: + literature_summary = f"Lit reviews: {len(literature_rows)}" + if literature_runtime: + literature_summary += f" ({format_runtime(literature_runtime)})" + summary_lines.append(literature_summary) + summary_lines.append(f"Best run: #{best_row.index} {record_label(best_row, 36)}") + ax.text( + 0.015, + 0.985, + "\n".join(summary_lines), + transform=ax.transAxes, + ha="left", + va="top", + fontsize=9, + bbox={ + "boxstyle": "round,pad=0.35", + "facecolor": "white", + "edgecolor": "#dddddd", + "alpha": 0.9, + }, + ) + + clipped = sum(score < y_limits[0] or score > y_limits[1] for score in observed_scores) + if clipped: + ax.text( + 0.99, + 0.015, + f"{clipped} outlier{'s' if clipped != 1 else ''} outside displayed range", + transform=ax.transAxes, + ha="right", + va="bottom", + fontsize=8, + color="#666666", + ) + + ax.legend(loc="best", fontsize=9) + output.parent.mkdir(parents=True, exist_ok=True) + temp_path = None + try: + with tempfile.NamedTemporaryFile( + dir=output.parent, prefix=f".{output.stem}.", suffix=".png", delete=False + ) as f: + temp_path = Path(f.name) + plt.tight_layout() + fig.savefig(temp_path, dpi=150, facecolor="white", transparent=False) + os.replace(temp_path, output) + finally: + plt.close(fig) + if temp_path and temp_path.exists(): + temp_path.unlink() + return baseline, best_score + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", nargs="?", default="results.tsv", help="path to the Auto-FL TSV ledger") + parser.add_argument("--output", default="progress.png", help="output PNG path") + parser.add_argument("--mode", choices=["max", "min"], default="max") + parser.add_argument("--metric", default="score", help="metric label shown in the plot") + parser.add_argument("--max-labels", type=int, default=6) + parser.add_argument("--max-literature-labels", type=int, default=4) + parser.add_argument("--full-y-range", action="store_true") + args = parser.parse_args(argv) + + records = load_results(Path(args.path)) + baseline, best = plot_progress( + records, + Path(args.output), + args.mode, + args.metric, + max_labels=args.max_labels, + max_literature_labels=args.max_literature_labels, + full_y_range=args.full_y_range, + ) + print(f"Saved {args.output}") + print(f"baseline={baseline:.6f}") + print(f"best={best:.6f}") + print(f"delta={best - baseline:+.6f}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index a272337481..dcc77f2e12 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -1554,7 +1554,18 @@ def write_state( return state -def write_progress(path: Path, records: List[RunRecord], mode: str, metric_label: str) -> None: +def load_progress_plotter(): + script_path = Path(__file__).with_name("plot_progress.py") + spec = importlib.util.spec_from_file_location("nvflare_autofl_plot_progress", script_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load Auto-FL progress plotter from {script_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def write_progress_fallback(path: Path, records: List[RunRecord], mode: str, metric_label: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) try: from PIL import Image, ImageDraw, ImageFont @@ -1603,6 +1614,14 @@ def write_progress(path: Path, records: List[RunRecord], mode: str, metric_label image.save(path) +def write_progress(path: Path, records: List[RunRecord], mode: str, metric_label: str) -> None: + plotter = load_progress_plotter() + try: + plotter.plot_progress(records, path, mode, metric_label) + except (plotter.NoScoredResultsError, plotter.PlotDependencyError): + write_progress_fallback(path, records, mode, metric_label) + + def write_report(path: Path, config: Dict[str, Any], records: List[RunRecord], args: argparse.Namespace) -> None: path.parent.mkdir(parents=True, exist_ok=True) best = None diff --git a/tests/unit_test/tool/autofl_skill_plot_progress_test.py b/tests/unit_test/tool/autofl_skill_plot_progress_test.py new file mode 100644 index 0000000000..2cb59a2f0b --- /dev/null +++ b/tests/unit_test/tool/autofl_skill_plot_progress_test.py @@ -0,0 +1,127 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import csv +import importlib.util +import sys +from pathlib import Path + +import pytest + + +def _load_plotter(): + repo_root = Path(__file__).parents[3] + script_path = repo_root / "skills" / "nvflare-autofl" / "scripts" / "plot_progress.py" + spec = importlib.util.spec_from_file_location("nvflare_autofl_skill_plot_progress", script_path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _record(plotter, index, score, status="discard", name=None, runtime=300.0): + return plotter.ProgressRecord( + index=index, + status=status, + name=name or f"candidate_{index}", + score=score, + runtime_seconds=runtime, + description=f"candidate {index}", + ) + + +def test_robust_y_limits_focus_on_improvement_region_for_maximize(): + plotter = _load_plotter() + scores = [0.50, 0.55, 0.687] + [0.70 + index * 0.002 for index in range(20)] + + lower, upper = plotter.default_y_limits(scores, baseline=0.687, mode="max") + + assert lower > 0.60 + assert lower < 0.687 + assert upper > max(scores) + + +def test_robust_y_limits_focus_on_improvement_region_for_minimize(): + plotter = _load_plotter() + scores = [2.0, 1.8, 0.90] + [0.80 - index * 0.01 for index in range(20)] + + lower, upper = plotter.default_y_limits(scores, baseline=0.90, mode="min") + + assert lower < min(scores) + assert upper > 0.90 + assert upper < 1.5 + + +@pytest.mark.parametrize( + "mode,scores,expected_best", + [ + ("max", [0.5, 0.6, 0.55, 0.7, 0.69, 0.8], 0.8), + ("min", [0.8, 0.7, 0.75, 0.6, 0.62, 0.5], 0.5), + ], +) +def test_milestone_selection_supports_both_objective_directions(mode, scores, expected_best): + plotter = _load_plotter() + records = [ + _record(plotter, index, score, status="baseline" if index == 0 else "keep") + for index, score in enumerate(scores) + ] + + milestones = plotter.select_observed_milestones(records, mode=mode, max_labels=3) + + assert len(milestones) <= 3 + assert milestones[-1][1].score == expected_best + + +def test_load_results_uses_productized_ledger_fields(tmp_path): + plotter = _load_plotter() + ledger = tmp_path / "results.tsv" + with ledger.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter( + f, + fieldnames=["status", "name", "score", "runtime_seconds", "diff_summary"], + delimiter="\t", + ) + writer.writeheader() + writer.writerow( + { + "status": "keep", + "name": "fedavgm", + "score": "0.75", + "runtime_seconds": "123.5", + "diff_summary": "server momentum", + } + ) + + records = plotter.load_results(ledger) + + assert records == [plotter.ProgressRecord(0, "keep", "fedavgm", 0.75, 123.5, "server momentum")] + assert plotter.normalize_records(records) == records + + +def test_rich_progress_plot_renders_png(tmp_path): + pytest.importorskip("matplotlib") + plotter = _load_plotter() + records = [_record(plotter, 0, 0.687, status="baseline", name="baseline")] + for index in range(1, 21): + status = "keep" if index in {3, 8, 15} else "discard" + records.append(_record(plotter, index, 0.69 + index * 0.002, status=status)) + records.insert(10, _record(plotter, 10, None, status="literature", name="literature_review_1", runtime=45.0)) + output = tmp_path / "progress.png" + + baseline, best = plotter.plot_progress(records, output, mode="max", metric_label="test_accuracy") + + assert baseline == pytest.approx(0.687) + assert best == pytest.approx(0.73) + assert output.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + assert output.stat().st_size > 20_000 diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index 3a06958d38..db0cf57685 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -61,6 +61,7 @@ def _initialize_fake_campaign(runner, tmp_path, monkeypatch, *, target_env="sim" config["environment"]["requested"] = target_env monkeypatch.setattr(runner, "import_job_config", lambda *args, **kwargs: deepcopy(config)) monkeypatch.setattr(runner, "job_help", lambda *args, **kwargs: "") + monkeypatch.setattr(runner, "write_progress", lambda path, *args: path.write_bytes(b"progress")) def fake_run(run_def, **kwargs): return runner.RunRecord( From 64a902fd1ef28efdd07a73abe65affc680e41dbf Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Thu, 2 Jul 2026 12:51:17 -0400 Subject: [PATCH 21/23] Harden Auto-FL campaign execution Signed-off-by: Holger Roth --- docs/design/autofl_skill.md | 17 +- docs/user_guide/nvflare_cli/autofl_skill.rst | 23 +- nvflare/app_common/autofl/job_importer.py | 92 +++-- skills/nvflare-autofl/SKILL.md | 6 +- .../references/continuous-campaigns.md | 20 +- .../nvflare-autofl/scripts/campaign_guard.py | 72 ++-- .../nvflare-autofl/scripts/plot_progress.py | 3 +- .../scripts/run_job_campaign.py | 320 ++++++++++++++---- .../app_common/autofl/job_importer_test.py | 115 +++++++ .../tool/autofl_skill_campaign_guard_test.py | 98 +++++- .../tool/autofl_skill_plot_progress_test.py | 10 + .../tool/autofl_skill_runner_test.py | 264 ++++++++++++++- 12 files changed, 893 insertions(+), 147 deletions(-) diff --git a/docs/design/autofl_skill.md b/docs/design/autofl_skill.md index 46d15b4b90..ad94e8eb44 100644 --- a/docs/design/autofl_skill.md +++ b/docs/design/autofl_skill.md @@ -58,12 +58,16 @@ override campaign knobs explicitly. ## Deterministic Import The importer parses Python source with `ast`; it does not import or execute -user code. It supports known Recipe and FedJob-style patterns first and focuses +user code. It resolves direct imports, import aliases, and module aliases for +known Recipe surfaces and NVFlare-distributed classes whose names end in +`Job`. The generic API `Job`, local subclasses, and non-NVFlare subclasses stay +explicitly unresolved. The importer focuses on campaign-relevant settings rather than duplicating the full exported job: - Recipe/FedJob constructor and class import. - `SimEnv`, `PocEnv`, and `ProdEnv` references. -- `train_script` resolution for literal and argparse-derived values. +- `train_script` resolution for literal and argparse-derived values, or for one + unambiguous NVFlare `ScriptRunner(script=...)` call. - Objective metric from user request, `key_metric`, or explicit unresolved default. - Fixed-budget fields such as rounds, clients, and candidate budget. @@ -79,6 +83,9 @@ config inspection as a validation aid when available. Unsupported or dynamic fields are carried forward as unresolved review items instead of being guessed by the importer or the agent. +The runner writes this reviewable `autofl.yaml` before admission and refuses to +start a baseline when the job surface or fixed comparison budget remains +safety-critical and unresolved. ## Trust Contract @@ -135,6 +142,12 @@ Production is a valid optimization environment. The best candidate may later be submitted or reused through the standard NVFlare job lifecycle; no separate promotion command is needed. +The runner is the sole writer of `.nvflare/autofl/campaign_state.json`. Its +`status` action rescans the ledger, pending manifests, stop files, and cap before +refreshing state. The standalone campaign guard is a read-only diagnostic and +cannot overwrite runner metadata. Pending prepared or externally ready +candidates take precedence over stop files, cap exhaustion, and final reporting. + ## Review Questions - Are the supported `job.py` patterns sufficient for an initial prototype? diff --git a/docs/user_guide/nvflare_cli/autofl_skill.rst b/docs/user_guide/nvflare_cli/autofl_skill.rst index b3a4ee6d5a..436b80ea6b 100644 --- a/docs/user_guide/nvflare_cli/autofl_skill.rst +++ b/docs/user_guide/nvflare_cli/autofl_skill.rst @@ -47,9 +47,15 @@ The skill first imports the job without executing user code: --max-candidates 8 \ --output autofl.yaml -The importer parses supported Recipe and FedJob patterns with Python AST -inspection. It extracts campaign-relevant settings into ``autofl.yaml`` and -marks unknown or dynamic fields as unresolved instead of guessing. +The importer parses supported Recipe patterns and NVFlare-distributed ``*Job`` +classes with Python AST inspection, including direct imports, aliases, and +module aliases. It can resolve one unambiguous +``ScriptRunner(script=...)`` when a FedJob constructor has no train-script +argument. It extracts campaign-relevant settings into ``autofl.yaml`` and +marks local job subclasses, ambiguous scripts, and dynamic fields as unresolved +instead of guessing. The helper leaves ``autofl.yaml`` available for review but +refuses baseline execution while the job surface or fixed comparison budget is +safety-critical and unresolved. Trust Contract ============== @@ -104,6 +110,13 @@ best. Built-in tunable candidates are available through the helper's ``suggest`` action only as optional seeds; the agent remains free to implement new algorithms. +The helper's ``status`` action rescans pending manifests, stop files, the +candidate cap, and the ledger before refreshing the authoritative +``.nvflare/autofl/campaign_state.json``. The standalone ``campaign_guard.py`` +is a read-only diagnostic and never writes campaign state. Pending candidate +work must be evaluated or abandoned before cap exhaustion or a stop file can +permit final reporting. + The workflow then uses existing NVFlare execution surfaces: - Simulation jobs run through the job's configured ``SimEnv``. @@ -118,7 +131,9 @@ Supported First Version The first version is intentionally narrow: -- Supported job surfaces: NVFlare Recipe constructors and FedJob-style scripts. +- Supported job surfaces: NVFlare Recipe constructors and imported + NVFlare-distributed classes ending in ``Job``. Generic ``Job`` and local or + non-NVFlare subclasses remain unresolved. - Supported import fields: objective metric, fixed budget fields, environment, train script, allowed edit paths, and common argparse tunables. - Unsupported or ambiguous custom Python is preserved as unresolved review diff --git a/nvflare/app_common/autofl/job_importer.py b/nvflare/app_common/autofl/job_importer.py index 343f495911..fdda2a45cb 100644 --- a/nvflare/app_common/autofl/job_importer.py +++ b/nvflare/app_common/autofl/job_importer.py @@ -142,12 +142,19 @@ def import_job( index = _ImportIndex.from_tree(tree, source_text) job_call = index.first_job_call() env_call = index.first_env_call() - train_script = self._resolve_train_script(source_path, job_call, index.parser_args, source_text) + train_script = self._resolve_train_script(source_path, job_call, index, source_text) train_args = _collect_argparse_args_from_file(train_script) if train_script else {} unresolved: List[Dict[str, str]] = [] if not job_call: - unresolved.append(_unresolved("job.surface", "no supported Recipe or FedJob constructor was found")) + unsupported = index.first_unsupported_job_call() + reason = "no supported Recipe or NVFlare FedJob constructor was found" + if unsupported: + reason = ( + f"constructor {unsupported.full_name} is a local or non-NVFlare Job subclass; " + "its contract cannot be imported deterministically" + ) + unresolved.append(_unresolved("job.surface", reason)) if not train_script: unresolved.append(_unresolved("job.train_script", "no train_script was found or resolved")) @@ -177,7 +184,7 @@ def import_job( job_payload.update( { "recipe": job_call.name, - "recipe_class": index.imports.get(job_call.name, job_call.full_name), + "recipe_class": job_call.full_name, "recipe_args": call_args, } ) @@ -185,7 +192,7 @@ def import_job( job_payload.update( { "fed_job": job_call.name, - "fed_job_class": index.imports.get(job_call.name, job_call.full_name), + "fed_job_class": job_call.full_name, "fed_job_args": call_args, } ) @@ -268,21 +275,31 @@ def _resolve_train_script( self, source_path: Path, job_call: Optional[CallInfo], - parser_args: Dict[str, ArgSpec], + index: "_ImportIndex", source_text: str, ) -> Optional[Path]: if not job_call: - return _existing_path(source_path.parent / "client.py") + return None train_script_node = job_call.keywords.get("train_script") - if not train_script_node: - return _existing_path(source_path.parent / "client.py") - - resolved = _resolve_value(train_script_node, job_call.assignments, parser_args, source_text) - value = resolved.value - if isinstance(value, str) and _is_resolved_path_string(resolved): - return _existing_path((source_path.parent / value).resolve()) - return None + if train_script_node: + resolved = _resolve_value(train_script_node, job_call.assignments, index.parser_args, source_text) + value = resolved.value + if isinstance(value, str) and _is_resolved_path_string(resolved): + return _existing_path((source_path.parent / value).resolve()) + return None + + script_paths = set() + for runner_call in index.script_runner_calls: + script_node = runner_call.keywords.get("script") + if not script_node: + continue + resolved = _resolve_value(script_node, runner_call.assignments, index.parser_args, source_text) + if isinstance(resolved.value, str) and _is_resolved_path_string(resolved): + path = _existing_path((source_path.parent / resolved.value).resolve()) + if path: + script_paths.add(path) + return next(iter(script_paths)) if len(script_paths) == 1 else None def _resolve_metric( self, @@ -329,6 +346,14 @@ def _resolve_budget( unresolved.append(_unresolved(f"budget.fixed_training_budget.{output_key}", resolved.source)) else: fixed_training_budget[output_key] = resolved.value + if "n_clients" in job_call.keywords: + resolved = _resolve_value( + job_call.keywords["n_clients"], job_call.assignments, parser_args, source_text + ) + if resolved.unresolved: + unresolved.append(_unresolved("budget.fixed_training_budget.num_clients", resolved.source)) + else: + fixed_training_budget["num_clients"] = resolved.value if env_call and env_call.name == "SimEnv" and "num_clients" in env_call.keywords: resolved = _resolve_value(env_call.keywords["num_clients"], env_call.assignments, parser_args, source_text) @@ -467,6 +492,8 @@ def __init__(self, source_text: str): self._local_assignments_stack: List[Dict[str, ast.AST]] = [] self._function_stack: List[str] = [] self.job_calls: List[CallInfo] = [] + self.unsupported_job_calls: List[CallInfo] = [] + self.script_runner_calls: List[CallInfo] = [] self.env_calls: List[CallInfo] = [] @classmethod @@ -481,6 +508,9 @@ def first_job_call(self) -> Optional[CallInfo]: def first_env_call(self) -> Optional[CallInfo]: return self.env_calls[0] if self.env_calls else None + def first_unsupported_job_call(self) -> Optional[CallInfo]: + return self.unsupported_job_calls[0] if self.unsupported_job_calls else None + def visit_Import(self, node: ast.Import) -> None: for alias in node.names: self.imports[alias.asname or alias.name] = alias.name @@ -515,22 +545,40 @@ def visit_Call(self, node: ast.Call) -> None: if arg_spec: self.parser_args[arg_spec.name] = arg_spec - short_name = call_name.split(".")[-1] - if _is_supported_job_call_name(short_name) or short_name in SUPPORTED_ENV_NAMES: + resolved_name = self._resolve_import_path(call_name) + short_name = resolved_name.split(".")[-1] + is_supported_job = _is_supported_job_call(short_name, resolved_name) + is_environment = short_name in SUPPORTED_ENV_NAMES + is_script_runner = short_name == "ScriptRunner" and resolved_name.startswith("nvflare.") + is_unsupported_job = short_name.endswith("Job") and not is_supported_job and short_name != "Job" + if is_supported_job or is_environment or is_script_runner or is_unsupported_job: call_info = CallInfo( name=short_name, - full_name=call_name, + full_name=resolved_name, keywords={keyword.arg: keyword.value for keyword in node.keywords if keyword.arg}, assignments=self._resolution_assignments(), source=_source_segment(self.source_text, node), function_name=self._function_stack[-1] if self._function_stack else None, ) - if short_name in SUPPORTED_ENV_NAMES: + if is_environment: self.env_calls.append(call_info) - else: + elif is_script_runner: + self.script_runner_calls.append(call_info) + elif is_supported_job: self.job_calls.append(call_info) + else: + self.unsupported_job_calls.append(call_info) self.generic_visit(node) + def _resolve_import_path(self, call_name: str) -> str: + if not call_name: + return call_name + root, separator, remainder = call_name.partition(".") + imported = self.imports.get(root) + if not imported: + return call_name + return f"{imported}.{remainder}" if separator else imported + def _current_assignments(self) -> Dict[str, ast.AST]: if self._local_assignments_stack: return self._local_assignments_stack[-1] @@ -714,8 +762,10 @@ def _is_argparse_add_argument_call(call_name: str) -> bool: return call_name.endswith(".add_argument") -def _is_supported_job_call_name(name: str) -> bool: - return name.endswith("Recipe") or name in {"BaseFedJob", "FedJob"} +def _is_supported_job_call(name: str, resolved_name: str) -> bool: + if name.endswith("Recipe"): + return True + return resolved_name.startswith("nvflare.") and name.endswith("Job") and name != "Job" def _is_recipe_call(call_info: CallInfo) -> bool: diff --git a/skills/nvflare-autofl/SKILL.md b/skills/nvflare-autofl/SKILL.md index e471a11c45..33a955ae67 100644 --- a/skills/nvflare-autofl/SKILL.md +++ b/skills/nvflare-autofl/SKILL.md @@ -173,9 +173,11 @@ infrastructure-only retries. Count a real candidate crash after execution starts. State must report `candidate_cap_source=explicit` or `uncapped`. Treat plateau as a decision checkpoint, not an automatic stop. Summarize the -plateau in the running report, refresh `progress.png`, run the campaign guard or -read the runner's `.nvflare/autofl/campaign_state.json`, choose the returned +plateau in the running report, refresh `progress.png`, run the runner's `status` +action to refresh `.nvflare/autofl/campaign_state.json`, choose the returned next mode, and continue unless the state reports `final_response_allowed=true`. +Use `campaign_guard.py` only for read-only ledger diagnostics; it never updates +authoritative campaign state. After a source-backed review, record it with the helper's `record --literature --hypothesis ""` action before preparing its candidate. diff --git a/skills/nvflare-autofl/references/continuous-campaigns.md b/skills/nvflare-autofl/references/continuous-campaigns.md index 81e6c7b299..0105008e60 100644 --- a/skills/nvflare-autofl/references/continuous-campaigns.md +++ b/skills/nvflare-autofl/references/continuous-campaigns.md @@ -25,21 +25,31 @@ child process remain active, keep waiting. ## Campaign Guards -The product runner writes `.nvflare/autofl/campaign_state.json` through -`scripts/campaign_guard.py`; read that state before any final response. This -product state is authoritative. If it has `final_response_allowed=false`, -execute `next_action` immediately; the skill text is only the interaction layer. +The product runner is the sole writer of +`.nvflare/autofl/campaign_state.json`; read that state before any final +response. `scripts/campaign_guard.py` is a read-only ledger diagnostic. To +rescan pending manifests, stop files, caps, and the ledger and refresh +authoritative state, run the runner's `status` action. If state has +`final_response_allowed=false`, execute `next_action` immediately; the skill +text is only the interaction layer. Common next actions: -- `edit_candidate` or `evaluate_candidate`: finish the pending candidate draft. +- `repair_baseline`: fix the deterministic import, execution, or metric issue, + then retry initialization. +- `edit_candidate`: finish the pending candidate draft, then invoke the runner's + `evaluate` lifecycle action. - `propose_candidate`: form a hypothesis, prepare its manifest, and edit the returned candidate source directory. - `submit_baseline` or `submit_candidate`: use the standard POC/production job lifecycle, then call `record` with its job ID and artifacts. +- `rerun_with_escalated_execution`: retry the same lifecycle action after + repairing runtime permissions; do not count it as a candidate. - `run_literature_loop`: run a short source-backed literature pass, record a non-scored `literature` row when a ledger is available, then launch the next compatible same-budget candidates. +- `final_report`: generate final artifacts only after the runner permits a final + response. After every finalized batch, run the available plateau or progress watchdog when the task provides one. If it recommends `continue`, refresh `progress.png` and diff --git a/skills/nvflare-autofl/scripts/campaign_guard.py b/skills/nvflare-autofl/scripts/campaign_guard.py index b3ccc7b658..f5a668de68 100644 --- a/skills/nvflare-autofl/scripts/campaign_guard.py +++ b/skills/nvflare-autofl/scripts/campaign_guard.py @@ -13,12 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Own the product Auto-FL campaign continuation decision. +"""Diagnose the product Auto-FL campaign continuation decision. The skill runner executes candidates and writes ``results.tsv``. This guard -turns that ledger into a machine-readable campaign state so a live coding agent -does not decide completion, plateau handling, or literature-mode transitions by -itself. +turns that ledger into a machine-readable decision. The campaign runner is the +only writer of authoritative campaign state. """ from __future__ import annotations @@ -34,9 +33,9 @@ DEFAULT_HARD_CRASH_THRESHOLD = 6 DEFAULT_MIN_DELTA = 0.0005 DEFAULT_PLATEAU_THRESHOLD = 8 -DEFAULT_STATE_PATH = ".nvflare/autofl/campaign_state.json" DEFAULT_STOP_FILES = ("STOP_AUTOFL", ".nvflare/autofl/STOP") -COMPARABLE_STATUSES = {"candidate", "keep", "discard", "crash"} +ATTEMPT_STATUSES = {"candidate", "keep", "discard", "crash"} +SCORED_ATTEMPT_STATUSES = {"keep", "discard"} LITERATURE_EVENT_STATUSES = {"event", "literature", "checkpoint"} @@ -86,7 +85,7 @@ def is_literature_event(row: Dict[str, str]) -> bool: def comparable_attempts(rows: List[Dict[str, str]]) -> List[Dict[str, str]]: - return [row for row in rows if normalize_status(row) in COMPARABLE_STATUSES and not is_baseline(row)] + return [row for row in rows if normalize_status(row) in ATTEMPT_STATUSES and not is_baseline(row)] def pending_candidates(rows: List[Dict[str, str]]) -> List[Dict[str, str]]: @@ -96,7 +95,7 @@ def pending_candidates(rows: List[Dict[str, str]]) -> List[Dict[str, str]]: def scored_attempts_with_index(rows: List[Dict[str, str]]) -> List[Tuple[int, Dict[str, str], float]]: scored = [] for idx, row in enumerate(rows): - if normalize_status(row) not in COMPARABLE_STATUSES or is_baseline(row): + if normalize_status(row) not in SCORED_ATTEMPT_STATUSES or is_baseline(row): continue score = parse_score(row.get("score", "")) if score is not None: @@ -115,7 +114,9 @@ def better(new_score: float, old_score: Optional[float], mode: str, min_delta: f def best_score(rows: List[Dict[str, str]], mode: str) -> Optional[float]: best = None for row in rows: - if normalize_status(row) not in COMPARABLE_STATUSES and not is_baseline(row): + status = normalize_status(row) + retained = status == "keep" or (is_baseline(row) and status not in ATTEMPT_STATUSES) + if not retained: continue score = parse_score(row.get("score", "")) if score is None: @@ -126,14 +127,17 @@ def best_score(rows: List[Dict[str, str]], mode: str) -> Optional[float]: def plateau_status(rows: List[Dict[str, str]], threshold: int, min_delta: float, mode: str) -> Dict[str, Any]: - scored = [] + retained = [] for idx, row in enumerate(rows): - if normalize_status(row) not in COMPARABLE_STATUSES and not is_baseline(row): + status = normalize_status(row) + is_retained = status == "keep" or (is_baseline(row) and status not in ATTEMPT_STATUSES) + if not is_retained: continue score = parse_score(row.get("score", "")) if score is not None: - scored.append((idx, row, score)) - if threshold <= 0 or not scored: + retained.append((idx, row, score)) + scored_attempts = scored_attempts_with_index(rows) + if threshold <= 0 or not retained: return { "available": True, "recommendation": "continue", @@ -146,7 +150,7 @@ def plateau_status(rows: List[Dict[str, str]], threshold: int, min_delta: float, best_row_idx = -1 best_scored_idx = -1 best_name = "" - for scored_idx, (row_idx, row, score) in enumerate(scored): + for scored_idx, (row_idx, row, score) in enumerate(retained): if better(score, best, mode, min_delta): best = score best_row_idx = row_idx @@ -155,7 +159,7 @@ def plateau_status(rows: List[Dict[str, str]], threshold: int, min_delta: float, last_literature_idx = max((idx for idx, row in enumerate(rows) if is_literature_event(row)), default=-1) reset_row_idx = max(best_row_idx, last_literature_idx) - scored_since_reset = sum(1 for row_idx, _, _ in scored if row_idx > reset_row_idx) + scored_since_reset = sum(1 for row_idx, _, _ in scored_attempts if row_idx > reset_row_idx) recommendation = "literature" if scored_since_reset >= threshold else "continue" return { "available": True, @@ -210,6 +214,7 @@ def guard_state_for_rows( min_delta: float = DEFAULT_MIN_DELTA, hard_crash_threshold: int = DEFAULT_HARD_CRASH_THRESHOLD, mode: str = "max", + pending_manifest_count: int = 0, ) -> Dict[str, Any]: attempts = comparable_attempts(rows) pending = pending_candidates(rows) @@ -223,9 +228,10 @@ def guard_state_for_rows( next_action = "propose_candidate" final_response_allowed = False - if pending: + pending_count = len(pending) + pending_manifest_count + if pending_count: reason = "pending_candidates" - next_action = "finalize_pending_candidates" + next_action = "edit_candidate" elif stop_file_hits: decision = "stop" reason = "manual_stop_file" @@ -246,11 +252,9 @@ def guard_state_for_rows( next_action = "run_literature_loop" if final_response_allowed: - instruction = "Final report is allowed because the campaign guard reached a stop condition." - elif next_action == "finalize_pending_candidates": - instruction = ( - "Do not produce a final answer. Finalize reviewed candidate rows, refresh artifacts, then rerun the guard." - ) + instruction = "Final report is allowed because authoritative campaign state reached a stop condition." + elif next_action == "edit_candidate": + instruction = "Do not produce a final answer. Finish the pending candidate, then run the runner status action." elif next_action == "run_literature_loop": instruction = ( "Do not produce a final answer. Run the literature loop, record a literature event, " @@ -270,7 +274,7 @@ def guard_state_for_rows( "candidate_cap": cap, "candidate_cap_source": cap_source, "candidate_attempts": len(attempts), - "pending_candidates": len(pending), + "pending_candidates": pending_count, "scored_attempts": len(scored_attempts_with_index(rows)), "best_score": best_score(rows, mode), "stop_files": stop_file_hits, @@ -288,6 +292,7 @@ def guard_state( min_delta: float = DEFAULT_MIN_DELTA, hard_crash_threshold: int = DEFAULT_HARD_CRASH_THRESHOLD, mode: str = "max", + pending_manifest_count: int = 0, ) -> Dict[str, Any]: return guard_state_for_rows( load_rows(results_path), @@ -298,16 +303,10 @@ def guard_state( min_delta=min_delta, hard_crash_threshold=hard_crash_threshold, mode=mode, + pending_manifest_count=pending_manifest_count, ) -def write_state(path: Path, state: Dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = path.with_suffix(path.suffix + ".tmp") - tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") - tmp_path.replace(path) - - def print_text(state: Dict[str, Any]) -> None: for key in [ "decision", @@ -333,9 +332,8 @@ def print_text(state: Dict[str, Any]) -> None: def main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("results", nargs="?", default="results.tsv") - parser.add_argument("--state", default=DEFAULT_STATE_PATH) parser.add_argument("--max-candidates", type=parse_max_candidates_arg) - parser.add_argument("--stop-file", action="append", default=list(DEFAULT_STOP_FILES)) + parser.add_argument("--stop-file", action="append") parser.add_argument("--plateau-threshold", type=int, default=DEFAULT_PLATEAU_THRESHOLD) parser.add_argument("--min-delta", type=float, default=DEFAULT_MIN_DELTA) parser.add_argument("--hard-crash-threshold", type=int, default=DEFAULT_HARD_CRASH_THRESHOLD) @@ -348,16 +346,20 @@ def main(argv: Optional[List[str]] = None) -> int: if args.min_delta < 0: raise ValueError("--min-delta must be non-negative") + results_path = Path(args.results).resolve() + stop_files = args.stop_file if args.stop_file is not None else list(DEFAULT_STOP_FILES) + resolved_stop_files = [ + str(path if path.is_absolute() else results_path.parent / path) for path in map(Path, stop_files) + ] state = guard_state( - Path(args.results), + results_path, max_candidates=args.max_candidates, - stop_files=args.stop_file, + stop_files=resolved_stop_files, plateau_threshold=args.plateau_threshold, min_delta=args.min_delta, hard_crash_threshold=args.hard_crash_threshold, mode=args.mode, ) - write_state(Path(args.state), state) if args.format == "json": print(json.dumps(state, sort_keys=True)) else: diff --git a/skills/nvflare-autofl/scripts/plot_progress.py b/skills/nvflare-autofl/scripts/plot_progress.py index 764d7b1be2..9a03933a16 100755 --- a/skills/nvflare-autofl/scripts/plot_progress.py +++ b/skills/nvflare-autofl/scripts/plot_progress.py @@ -247,7 +247,8 @@ def label_placement( x_span = max(x_limits[1] - x_limits[0], 1.0) y_span = max(y_limits[1] - y_limits[0], 1e-9) x_fraction = (record.index - x_limits[0]) / x_span - y_fraction = ((record.score or y_limits[0]) - y_limits[0]) / y_span + score = record.score if record.score is not None else y_limits[0] + y_fraction = (score - y_limits[0]) / y_span near_right = x_fraction > 0.72 near_top = y_fraction > 0.78 x_offset = -10 if near_right else 10 diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index dcc77f2e12..5241ecd63d 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -23,19 +23,21 @@ from __future__ import annotations import argparse +import codecs import csv import difflib import hashlib import importlib.util import json import os +import queue import re -import selectors import shlex import shutil import signal import subprocess import sys +import threading import time import uuid from dataclasses import dataclass, field @@ -65,6 +67,7 @@ ] CANDIDATE_MANIFEST_SCHEMA_VERSION = "nvflare.autofl.candidate.v1" +CANDIDATE_MANIFEST_STATUSES = {"prepared", "ready_for_external_execution", "keep", "discard", "crash", "abandoned"} CAMPAIGN_METADATA_SCHEMA_VERSION = "nvflare.autofl.campaign.v1" CAMPAIGN_METADATA_PATH = ".nvflare/autofl/campaign.json" CANDIDATE_ROOT = ".nvflare/autofl/candidates" @@ -396,21 +399,35 @@ def run( cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, - bufsize=1, + text=False, + bufsize=0, start_new_session=os.name != "nt", ) assert process.stdout is not None - selector = selectors.DefaultSelector() - selector.register(process.stdout, selectors.EVENT_READ) + output_queue: queue.Queue[Optional[bytes]] = queue.Queue() + + def read_output() -> None: + try: + while True: + chunk = process.stdout.read(65536) + if not chunk: + break + output_queue.put(chunk) + finally: + output_queue.put(None) + + reader = threading.Thread(target=read_output, name="autofl-process-output", daemon=True) + reader.start() + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + output_finished = False timed_out = False stall_message = "" - while True: + while not output_finished or process.poll() is None: now = time.monotonic() - if timeout and now - started > timeout: + if not timed_out and timeout and now - started > timeout: timed_out = True terminate_process(process) - if simulator_stall_roots and now >= next_stall_check: + if not timed_out and not stall_message and simulator_stall_roots and now >= next_stall_check: stall_message = simulator_stall_message(simulator_stall_roots) or "" next_stall_check = now + stall_check_interval if stall_message: @@ -451,21 +468,24 @@ def run( ) terminate_process(process) last_progress_check = now - events = selector.select(timeout=0.2) - for key, _ in events: - chunk = key.fileobj.readline() - if chunk: - chunks.append(chunk) - log_file.write(chunk) - log_file.flush() - if process.poll() is not None: - remainder = process.stdout.read() - if remainder: - chunks.append(remainder) - log_file.write(remainder) - log_file.flush() - break - selector.close() + try: + raw_chunk = output_queue.get(timeout=0.2) + except queue.Empty: + continue + if raw_chunk is None: + output_finished = True + continue + chunk = decoder.decode(raw_chunk) + if chunk: + chunks.append(chunk) + log_file.write(chunk) + log_file.flush() + reader.join(timeout=1) + remainder = decoder.decode(b"", final=True) + if remainder: + chunks.append(remainder) + log_file.write(remainder) + log_file.flush() if timed_out: timeout_msg = f"\nTIMEOUT after {timeout}s\n" chunks.append(timeout_msg) @@ -705,14 +725,21 @@ def candidate_manifest_path(workspace: Path, candidate_id: str) -> Path: return workspace / CANDIDATE_ROOT / candidate_id / "candidate_manifest.json" -def load_candidate_manifest(path: Path) -> Dict[str, Any]: - manifest = read_json(path) +def validate_candidate_manifest_identity(path: Path, manifest: Dict[str, Any]) -> str: if manifest.get("schema_version") != CANDIDATE_MANIFEST_SCHEMA_VERSION: raise ValueError(f"unsupported candidate manifest schema in {path}") candidate_id = validate_candidate_id(str(manifest.get("candidate_id") or "")) expected = candidate_manifest_path(Path(str(manifest.get("workspace_root") or "")), candidate_id).resolve() if path.resolve() != expected: raise ValueError("candidate manifest path does not match its workspace and candidate ID") + if manifest.get("status") not in CANDIDATE_MANIFEST_STATUSES: + raise ValueError(f"candidate {candidate_id} has an invalid manifest status") + return candidate_id + + +def load_candidate_manifest(path: Path) -> Dict[str, Any]: + manifest = read_json(path) + candidate_id = validate_candidate_manifest_identity(path, manifest) if manifest.get("status") not in {"prepared", "ready_for_external_execution"}: raise ValueError(f"candidate {candidate_id} is not pending evaluation") return manifest @@ -830,7 +857,7 @@ def resolve_stop_files(cwd: Path, values: Optional[Sequence[str]]) -> List[str]: return [str(resolve_output_path(cwd, value)) for value in stop_files] -def extract_result_dir(output: str) -> Optional[Path]: +def extract_result_dir(output: str, cwd: Optional[Path] = None) -> Optional[Path]: patterns = [ r"Result can be found in\s*:\s*(?P\S+)", r"Results:\s*(?P\S+)", @@ -839,7 +866,10 @@ def extract_result_dir(output: str) -> Optional[Path]: for pattern in patterns: match = re.search(pattern, output) if match: - return Path(match.group("path")).expanduser() + path = Path(match.group("path")).expanduser() + if not path.is_absolute() and cwd is not None: + path = cwd / path + return path.resolve() return None @@ -939,39 +969,55 @@ def metric_from_list(items: Any, metric: str) -> Optional[float]: def extract_score(artifact_root: Path, metrics: Sequence[str] | str) -> Optional[float]: metric_order = normalize_metric_order(metrics) - metric_files = list(artifact_root.glob("**/metrics_summary.json")) + list( + metric_files = sorted(artifact_root.glob("**/metrics_summary.json")) + sorted( artifact_root.glob("**/cross_val_results.json") ) + payloads = [] for path in metric_files: try: payload = json.loads(path.read_text(encoding="utf-8")) except Exception: continue - score = find_metric_value(payload, metric_order) - if score is not None: - return score - - number_patterns = [rf"{re.escape(metric)}[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)" for metric in metric_order] - if "accuracy" in metric_order: - number_patterns.append(r"Accuracy of the network[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)") - for path in artifact_root.glob("**/*.log"): + payloads.append(payload) + for metric in metric_order: + for payload in payloads: + score = find_metric_value(payload, [metric]) + if score is not None: + return score + + text_artifacts = [] + for name in ("run.log", "log.txt", "log_fl.txt", "error_log.txt"): + text_artifacts.extend(sorted(artifact_root.glob(f"**/{name}"))) + texts = [] + for path in text_artifacts: try: - text = path.read_text(encoding="utf-8", errors="replace") + texts.append(path.read_text(encoding="utf-8", errors="replace")) except Exception: continue - for pattern in number_patterns: - matches = re.findall(pattern, text, flags=re.IGNORECASE) - if matches: - return float(matches[-1]) + for metric in metric_order: + number_patterns = [rf"{re.escape(metric)}[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)"] + if metric == "accuracy": + number_patterns.append(r"Accuracy of the network[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)") + for text in texts: + for pattern in number_patterns: + matches = re.findall(pattern, text, flags=re.IGNORECASE) + if matches: + return float(matches[-1]) return None +def metric_search_description(artifact_root: Path, metrics: Sequence[str] | str) -> str: + names = ["metrics_summary.json", "cross_val_results.json", "run.log", "log.txt", "log_fl.txt", "error_log.txt"] + searched = [str(path) for name in names for path in sorted(artifact_root.glob(f"**/{name}"))] + return f"metrics={normalize_metric_order(metrics)!r}; searched={searched or [str(artifact_root)]!r}" + + def is_sandbox_socket_failure(output: str) -> bool: text = output.lower() return ( "permissionerror" in text and ("operation not permitted" in text or "[errno 1]" in text) - and ("socket" in text or "sock" in text) + and ("socket" in text or "sock" in text or "get_open_ports" in text or ".bind(" in text) ) @@ -986,7 +1032,6 @@ def collect_artifacts(result_dir: Optional[Path], output_root: Path, name: str, shutil.rmtree(dest) if result_dir and result_dir.exists(): shutil.copytree(result_dir, dest) - shutil.rmtree(result_dir, ignore_errors=True) else: dest.mkdir(parents=True, exist_ok=True) if log_path.resolve() != run_log.resolve(): @@ -1006,8 +1051,12 @@ def job_help(python: str, job: Path, cwd: Path) -> str: return process.stdout +def supported_long_flags(help_text: str) -> set[str]: + return set(re.findall(r"(? bool: - return flag in help_text + return flag in supported_long_flags(help_text) def mutable_arg_specs(schema: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: @@ -1020,6 +1069,14 @@ def candidate_arg_values(candidate_args: Sequence[str]) -> Dict[str, Any]: idx = 0 while idx < len(candidate_args): raw = candidate_args[idx] + if raw.startswith("--") and not re.match(r"^--[A-Za-z][A-Za-z0-9_-]*(?:=.*)?$", raw): + raise ValueError(f"invalid canonical long option: {raw!r}") + if ( + raw.startswith("-") + and not raw.startswith("--") + and not re.match(r"^-(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$", raw) + ): + raise ValueError(f"candidate run arguments require canonical long options, not {raw!r}") if not raw.startswith("--"): idx += 1 continue @@ -1086,9 +1143,20 @@ def candidate_preserves_fixed_args( fixed_names = set(fixed_within_campaign(schema)) fixed_budget = config.get("budget", {}).get("fixed_training_budget", {}) or {} fixed_names.update(FIXED_BUDGET_TO_CLI[field] for field in fixed_budget if field in FIXED_BUDGET_TO_CLI) - changed = sorted(fixed_names.intersection(values)) + normalized_fixed_names = {name.replace("-", "_") for name in fixed_names} + changed = sorted(normalized_fixed_names.intersection(values)) if changed: return False, f"candidate run arguments change fixed-budget fields: {', '.join(changed)}" + for raw in candidate_args: + if not raw.startswith("--"): + continue + supplied = raw[2:].split("=", 1)[0].replace("-", "_") + abbreviated = sorted(name for name in normalized_fixed_names if name.startswith(supplied) and name != supplied) + if abbreviated: + return False, ( + f"candidate run argument {raw!r} is a strict prefix of fixed-budget option(s): " + f"{', '.join('--' + name for name in abbreviated)}" + ) return True, "" @@ -1341,11 +1409,35 @@ def uncapped() -> Iterable[JobRun]: return uncapped() -def remove_known_result_dir(config: Dict[str, Any]) -> None: - recipe_args = config.get("job", {}).get("recipe_args", {}) or {} - name = recipe_args.get("name", {}).get("value") if isinstance(recipe_args.get("name"), dict) else None - if isinstance(name, str) and name: - shutil.rmtree(Path("/tmp/nvflare/simulation") / name, ignore_errors=True) +def imported_job_names(config: Dict[str, Any]) -> List[str]: + names = [] + job_config = config.get("job", {}) + if not isinstance(job_config, dict): + return names + for key in ("recipe_args", "fed_job_args"): + arguments = job_config.get(key) + if not isinstance(arguments, dict): + continue + name = arguments.get("name") + value = name.get("value") if isinstance(name, dict) else name + if isinstance(name, dict) and name.get("confidence") != "high": + continue + if isinstance(value, str) and value and value not in names: + names.append(value) + return names + + +def expected_simulator_roots(config: Dict[str, Any], injected_name: Optional[str]) -> List[Path]: + names = ([injected_name] if injected_name else []) + imported_job_names(config) + roots = [] + simulator_base = Path("/tmp/nvflare/simulation") + for name in names: + if Path(name).name != name or name in {".", ".."}: + raise ValueError(f"unsafe simulator job name: {name!r}") + root = simulator_base / name + if root not in roots: + roots.append(root) + return roots def run_job( @@ -1365,23 +1457,29 @@ def run_job( ) -> RunRecord: log_path = output_root / run_def.name / "run.log" run_name = f"autofl_{run_def.name}" - simulator_root = Path("/tmp/nvflare/simulation") / run_name name_args = ["--name", run_name] if supports_flag(help_text, "--name") else [] + simulator_roots = expected_simulator_roots(config, run_name if name_args else None) command = [python, str(job), *fixed_args, *base_args, *name_args, *run_def.args] run_def.command = command - remove_known_result_dir(config) - if name_args: + for simulator_root in simulator_roots: shutil.rmtree(simulator_root, ignore_errors=True) rc, stdout, runtime = run_allow_timeout( command, cwd, timeout, log_path, - simulator_stall_roots=[simulator_root], + simulator_stall_roots=simulator_roots, simulator_no_progress_timeout=simulator_no_progress_timeout, ) run_def.runtime_seconds = runtime - result_dir = extract_result_dir(stdout) or (simulator_root if simulator_root.exists() else None) + printed_result_dir = extract_result_dir(stdout, cwd) + existing_roots = [root.resolve() for root in simulator_roots if root.exists()] + result_dir = printed_result_dir if printed_result_dir and printed_result_dir.exists() else None + injected_root = (Path("/tmp/nvflare/simulation") / run_name).resolve() if name_args else None + if result_dir is None and injected_root in existing_roots: + result_dir = injected_root + if result_dir is None and len(existing_roots) == 1: + result_dir = existing_roots[0] artifact_dir = collect_artifacts(result_dir, output_root, run_def.name, log_path) run_def.artifacts = str(artifact_dir) @@ -1398,11 +1496,18 @@ def run_job( else: run_def.status = "crash" run_def.failure_reason = f"exit_code={rc}" + elif result_dir is None: + run_def.status = "crash" + run_def.failure_reason = ( + "job exited successfully but no deterministic NVFlare result directory was resolved; " + "expose a literal job name or print the result path" + ) else: - score = extract_score(artifact_dir, metrics) + artifact_root = artifact_dir.parent + score = extract_score(artifact_root, metrics) if score is None: run_def.status = "crash" - run_def.failure_reason = f"metrics '{', '.join(metrics)}' not found" + run_def.failure_reason = f"matching metric not found; {metric_search_description(artifact_root, metrics)}" else: run_def.score = score @@ -1489,9 +1594,16 @@ def write_state( plateau_min_delta: float = DEFAULT_PLATEAU_MIN_DELTA, hard_crash_threshold: int = DEFAULT_HARD_CRASH_THRESHOLD, manual_stop: bool = False, + pending_manifest_count: int = 0, ) -> Dict[str, Any]: guard = load_campaign_guard() - attempts = len([r for r in records if r.status in {"keep", "discard", "crash"}]) + attempts = len( + [ + record + for record in records + if record.status in {"candidate", "keep", "discard", "crash"} and not is_baseline_record(record) + ] + ) if manual_stop: state = guard.guard_state( results_path, @@ -1501,7 +1613,11 @@ def write_state( min_delta=plateau_min_delta, hard_crash_threshold=hard_crash_threshold, mode=mode, + pending_manifest_count=pending_manifest_count, ) + if state.get("pending_candidates"): + write_json(path, state) + return state state.update( { "candidate_attempts": attempts, @@ -1524,6 +1640,7 @@ def write_state( min_delta=plateau_min_delta, hard_crash_threshold=hard_crash_threshold, mode=mode, + pending_manifest_count=pending_manifest_count, ) state.update( { @@ -1549,6 +1666,7 @@ def write_state( min_delta=plateau_min_delta, hard_crash_threshold=hard_crash_threshold, mode=mode, + pending_manifest_count=pending_manifest_count, ) write_json(path, state) return state @@ -1675,7 +1793,24 @@ def write_report(path: Path, config: Dict[str, Any], records: List[RunRecord], a def candidate_attempts(records: List[RunRecord]) -> int: - return len([r for r in records if r.status in {"keep", "discard", "crash"}]) + return len( + [ + record + for record in records + if record.status in {"candidate", "keep", "discard", "crash"} and not is_baseline_record(record) + ] + ) + + +def is_baseline_record(record: RunRecord) -> bool: + name = record.name.strip().lower() + command = record.run_command.lower() + return ( + record.status == "baseline" + or name == "baseline" + or name.startswith("baseline_") + or "--name baseline" in command + ) def campaign_summary( @@ -1738,6 +1873,7 @@ def campaign_paths(args: argparse.Namespace, job: Path) -> Dict[str, Path]: "plateau_threshold", "plateau_min_delta", "hard_crash_threshold", + "stop_file", "base_args", "timeout", "simulator_no_progress_timeout", @@ -1803,6 +1939,26 @@ def import_job_config( return read_yaml(output) +def campaign_admission_errors(config: Dict[str, Any]) -> List[str]: + errors = [] + support = config.get("import", {}).get("support", {}) + if not isinstance(support, dict) or support.get("status") != "supported": + errors.append("job surface is not deterministically supported") + fixed_budget = config.get("budget", {}).get("fixed_training_budget") + if not isinstance(fixed_budget, dict) or not fixed_budget: + errors.append("fixed comparison budget is unresolved") + unresolved = config.get("unresolved", []) + if isinstance(unresolved, list): + critical_fields = [] + for item in unresolved: + field = item.get("field", "") if isinstance(item, dict) else "" + if field.startswith("budget.fixed_training_budget"): + critical_fields.append(field) + if critical_fields: + errors.append(f"safety-critical fields are unresolved: {', '.join(sorted(set(critical_fields)))}") + return errors + + def best_retained_record(records: Sequence[RunRecord], mode: str) -> Optional[RunRecord]: best = None for record in records: @@ -1822,6 +1978,9 @@ def refresh_campaign_artifacts( next_action: Optional[str] = None, reason: Optional[str] = None, ) -> Dict[str, Any]: + pending_manifests = pending_candidate_manifests(paths["workspace"]) + if pending_manifest is not None and pending_manifest not in pending_manifests: + pending_manifests.append(pending_manifest) write_results(paths["results"], records) state = write_state( paths["state"], @@ -1833,7 +1992,12 @@ def refresh_campaign_artifacts( plateau_threshold=args.plateau_threshold, plateau_min_delta=args.plateau_min_delta, hard_crash_threshold=args.hard_crash_threshold, + pending_manifest_count=len(pending_manifests), ) + if pending_manifests and next_action is None: + pending_status = read_json(pending_manifests[0]).get("status") + next_action = "submit_candidate" if pending_status == "ready_for_external_execution" else "edit_candidate" + reason = "pending_candidates" if not state.get("final_response_allowed") and next_action: state["next_action"] = next_action state["reason"] = reason or next_action @@ -1842,7 +2006,7 @@ def refresh_campaign_artifacts( { "best_candidate": metadata.get("best_candidate"), "best_source_sha256": metadata.get("best_source_sha256"), - "pending_candidate_manifest": str(pending_manifest.resolve()) if pending_manifest else None, + "pending_candidate_manifest": str(pending_manifests[0].resolve()) if pending_manifests else None, } ) write_json(paths["state"], state) @@ -1873,11 +2037,12 @@ def execute_sim_baseline( paths: Dict[str, Path], config: Dict[str, Any], schema: Dict[str, Any], + name: str = "baseline", ) -> RunRecord: timeout, no_progress_timeout = campaign_timeout(args, schema) help_text = job_help(args.python, job, job.parent) return run_job( - JobRun(name="baseline", args=[], description="baseline", status="baseline"), + JobRun(name=name, args=[], description="baseline", status="baseline"), python=args.python, job=job, cwd=job.parent, @@ -1904,9 +2069,19 @@ def initialize_campaign(args: argparse.Namespace, job: Path) -> int: record.status == "baseline" and record.score is not None for record in records ): config = read_yaml(paths["autofl_yaml"]) - baseline = execute_sim_baseline(args, job, paths, config, load_mutation_schema(workspace)) - records = [baseline] + retry_number = sum(is_baseline_record(record) for record in records) + 1 + baseline = execute_sim_baseline( + args, + job, + paths, + config, + load_mutation_schema(workspace), + name=f"baseline_retry_{retry_number}", + ) + records.append(baseline) metadata["best_score"] = baseline.score + if baseline.score is not None: + metadata["best_candidate"] = baseline.name metadata["updated_at"] = utc_now() write_json(metadata_path, metadata) next_action = "propose_candidate" if baseline.score is not None else "repair_baseline" @@ -1929,6 +2104,12 @@ def initialize_campaign(args: argparse.Namespace, job: Path) -> int: config.setdefault("job", {})["allowed_create_patterns"] = list(ALLOWED_CREATE_PATTERNS) config.setdefault("trust_contract", {})["allowed_create_patterns"] = list(ALLOWED_CREATE_PATTERNS) write_yaml(paths["autofl_yaml"], config) + admission_errors = campaign_admission_errors(config) + if admission_errors: + raise ValueError( + f"autofl.yaml was generated at {paths['autofl_yaml']}, but baseline execution is unsafe: " + f"{'; '.join(admission_errors)}. Resolve these fields and initialize again." + ) snapshot_files = create_best_snapshot(workspace, config, paths["snapshot_root"]) metadata = { "schema_version": CAMPAIGN_METADATA_SCHEMA_VERSION, @@ -1972,10 +2153,9 @@ def pending_candidate_manifests(workspace: Path) -> List[Path]: if not root.exists(): return pending for path in sorted(root.glob("*/candidate_manifest.json")): - try: - status = read_json(path).get("status") - except ValueError: - continue + manifest = read_json(path) + validate_candidate_manifest_identity(path, manifest) + status = manifest.get("status") if status in {"prepared", "ready_for_external_execution"}: pending.append(path) return pending @@ -2271,7 +2451,7 @@ def evaluate_candidate(args: argparse.Namespace, job: Path) -> int: if fixed_budget_hash(candidate_config) != metadata.get("fixed_budget_sha256"): raise ValueError("candidate changes budget.fixed_training_budget") candidate_config = candidate_campaign_config(candidate_config, config, args, schema) - except Exception: + except BaseException: restore_best_source(workspace, best_source, best_files, changed) raise @@ -2548,7 +2728,9 @@ def show_campaign_status(args: argparse.Namespace, job: Path) -> int: restore_campaign_settings(args, metadata) paths = campaign_paths(args, job) records = load_results(paths["results"]) - print_campaign_result(paths, records, read_json(paths["state"]), campaign=metadata) + config = read_yaml(paths["autofl_yaml"]) + state = refresh_campaign_artifacts(args, paths, config, records, metadata) + print_campaign_result(paths, records, state, campaign=metadata) return 0 diff --git a/tests/unit_test/app_common/autofl/job_importer_test.py b/tests/unit_test/app_common/autofl/job_importer_test.py index d61a179ee9..77074d57b7 100644 --- a/tests/unit_test/app_common/autofl/job_importer_test.py +++ b/tests/unit_test/app_common/autofl/job_importer_test.py @@ -364,6 +364,121 @@ def main(): assert "budget.fixed_training_budget" in unresolved_fields +def test_import_resolves_nvflare_fed_job_alias_and_script_runner(tmp_path): + (tmp_path / "train.py").write_text("print('train')\n", encoding="utf-8") + job_path = tmp_path / "job.py" + job_path.write_text( + """ +from nvflare.app_common.workflows.fedavg import FedAvgJob as ImportedJob +from nvflare.app_common.executors.script_runner import ScriptRunner as Runner + + +def main(): + job = ImportedJob(name="fedavg-alias", n_clients=8, min_clients=4, num_rounds=10, key_metric="AUC") + runner = Runner(script="train.py") + return job, runner +""".lstrip(), + encoding="utf-8", + ) + + config = import_job_to_autofl_config(str(job_path), workspace_root=str(tmp_path)) + + assert config["import"]["support"]["status"] == "supported" + assert config["job"]["fed_job"] == "FedAvgJob" + assert config["job"]["fed_job_class"] == "nvflare.app_common.workflows.fedavg.FedAvgJob" + assert config["job"]["train_script"] == "train.py" + assert config["objective"] == _objective("AUC", source="literal") + assert config["budget"]["fixed_training_budget"] == { + "num_rounds": 10, + "min_clients": 4, + "num_clients": 8, + } + + +def test_import_resolves_module_aliases_for_nvflare_job_subclasses(tmp_path): + (tmp_path / "train.py").write_text("print('train')\n", encoding="utf-8") + job_path = tmp_path / "job.py" + job_path.write_text( + """ +import nvflare.app_common.workflows as workflows +import nvflare.app_common.executors.script_runner as runner_module + + +def main(): + job = workflows.CCWFJob(name="ccwf", n_clients=3, min_clients=2, num_rounds=4) + runner = runner_module.ScriptRunner(script="train.py") + return job, runner +""".lstrip(), + encoding="utf-8", + ) + + config = import_job_to_autofl_config(str(job_path), workspace_root=str(tmp_path)) + + assert config["job"]["fed_job"] == "CCWFJob" + assert config["job"]["fed_job_class"] == "nvflare.app_common.workflows.CCWFJob" + assert config["job"]["train_script"] == "train.py" + assert config["budget"]["fixed_training_budget"]["num_clients"] == 3 + + +def test_import_recognizes_future_nvflare_job_subclasses_but_not_generic_or_local_jobs(tmp_path): + (tmp_path / "train.py").write_text("print('train')\n", encoding="utf-8") + stats_job = tmp_path / "stats_job.py" + stats_job.write_text( + """ +from nvflare.app_common.workflows import StatsJob +from nvflare.app_common.executors.script_runner import ScriptRunner + +StatsJob(name="stats", n_clients=2, min_clients=2, num_rounds=1) +ScriptRunner(script="train.py") +""".lstrip(), + encoding="utf-8", + ) + assert import_job_to_autofl_config(str(stats_job), workspace_root=str(tmp_path))["job"]["fed_job"] == "StatsJob" + + local_job = tmp_path / "local_job.py" + local_job.write_text( + """ +class CustomJob: + pass + +CustomJob() +""".lstrip(), + encoding="utf-8", + ) + local_config = import_job_to_autofl_config(str(local_job), workspace_root=str(tmp_path)) + assert local_config["import"]["support"]["status"] == "partial" + assert "local or non-NVFlare Job subclass" in next( + item["reason"] for item in local_config["unresolved"] if item["field"] == "job.surface" + ) + + generic_job = tmp_path / "generic_job.py" + generic_job.write_text("from nvflare.apis.job_def import Job\nJob()\n", encoding="utf-8") + generic_config = import_job_to_autofl_config(str(generic_job), workspace_root=str(tmp_path)) + assert generic_config["import"]["support"]["status"] == "partial" + + +def test_import_leaves_multiple_script_runners_unresolved(tmp_path): + for name in ("train_a.py", "train_b.py"): + tmp_path.joinpath(name).write_text("print('train')\n", encoding="utf-8") + job_path = tmp_path / "job.py" + job_path.write_text( + """ +from nvflare.app_common.workflows import EdgeJob +from nvflare.app_common.executors.script_runner import ScriptRunner + +EdgeJob(name="edge", n_clients=2, num_rounds=1) +ScriptRunner(script="train_a.py") +ScriptRunner(script="train_b.py") +""".lstrip(), + encoding="utf-8", + ) + + config = import_job_to_autofl_config(str(job_path), workspace_root=str(tmp_path)) + + assert "train_script" not in config["job"] + assert {"field": "job.train_script", "reason": "no train_script was found or resolved"} in config["unresolved"] + + def test_main_returns_clean_error_for_missing_job(tmp_path, capsys): output_path = tmp_path / "autofl.yaml" diff --git a/tests/unit_test/tool/autofl_skill_campaign_guard_test.py b/tests/unit_test/tool/autofl_skill_campaign_guard_test.py index d3be2da472..484b6d3200 100644 --- a/tests/unit_test/tool/autofl_skill_campaign_guard_test.py +++ b/tests/unit_test/tool/autofl_skill_campaign_guard_test.py @@ -87,7 +87,7 @@ def test_guard_continues_uncapped_before_plateau(): assert state["candidate_cap"] is None assert state["candidate_cap_source"] == "uncapped" assert state["candidate_attempts"] == 2 - assert state["best_score"] == 0.851 + assert state["best_score"] == 0.85 def test_guard_routes_plateau_to_literature_without_finalizing(): @@ -152,11 +152,12 @@ def test_guard_ignores_ambient_candidate_cap(monkeypatch): assert state["final_response_allowed"] is False -def test_guard_cli_writes_campaign_state_json(tmp_path): +def test_guard_cli_is_diagnostic_only(tmp_path): repo_root = Path(__file__).parents[3] guard_path = repo_root / "skills" / "nvflare-autofl" / "scripts" / "campaign_guard.py" results_path = tmp_path / "results.tsv" state_path = tmp_path / "state.json" + state_path.write_text('{"authoritative": true}\n', encoding="utf-8") _write_results( results_path, [ @@ -171,8 +172,6 @@ def test_guard_cli_writes_campaign_state_json(tmp_path): sys.executable, str(guard_path), str(results_path), - "--state", - str(state_path), "--plateau-threshold", "2", "--format", @@ -184,5 +183,94 @@ def test_guard_cli_writes_campaign_state_json(tmp_path): ) payload = json.loads(proc.stdout) - assert json.loads(state_path.read_text(encoding="utf-8")) == payload + assert json.loads(state_path.read_text(encoding="utf-8")) == {"authoritative": True} assert payload["next_action"] == "run_literature_loop" + + +def test_guard_resolves_default_and_custom_stop_files_from_results_directory(tmp_path): + repo_root = Path(__file__).parents[3] + guard_path = repo_root / "skills" / "nvflare-autofl" / "scripts" / "campaign_guard.py" + results_path = tmp_path / "results.tsv" + _write_results(results_path, [_row("baseline", "baseline", "0.85")]) + tmp_path.joinpath("STOP_AUTOFL").touch() + + default_proc = subprocess.run( + [sys.executable, str(guard_path), str(results_path), "--format", "json"], + cwd=repo_root, + text=True, + capture_output=True, + check=True, + ) + custom_proc = subprocess.run( + [ + sys.executable, + str(guard_path), + str(results_path), + "--stop-file", + "CUSTOM_STOP", + "--format", + "json", + ], + cwd=repo_root, + text=True, + capture_output=True, + check=True, + ) + + assert json.loads(default_proc.stdout)["reason"] == "manual_stop_file" + assert json.loads(custom_proc.stdout)["reason"] == "continue" + + +def test_numeric_crashes_and_pending_candidates_do_not_change_retained_best_or_plateau(): + guard = _load_guard() + rows = [ + _row("baseline", "baseline", "0.85"), + _row("crash", "crashed_candidate", "0.99"), + _row("candidate", "pending_candidate", "0.98"), + _row("discard", "discarded_candidate", "0.84"), + ] + + state = guard.guard_state_for_rows(rows, plateau_threshold=1) + + assert state["best_score"] == 0.85 + assert state["scored_attempts"] == 1 + assert state["plateau"]["scored_since_reset"] == 1 + assert state["reason"] == "pending_candidates" + assert state["next_action"] == "edit_candidate" + + +def test_pending_manifest_takes_precedence_over_cap_and_stop_file(tmp_path): + guard = _load_guard() + stop_file = tmp_path / "STOP_AUTOFL" + stop_file.touch() + rows = [_row("baseline", "baseline", "0.85"), _row("discard", "candidate_1", "0.84")] + + state = guard.guard_state_for_rows( + rows, + max_candidates=1, + stop_files=[str(stop_file)], + pending_manifest_count=1, + ) + + assert state["decision"] == "continue" + assert state["reason"] == "pending_candidates" + assert state["final_response_allowed"] is False + + +def test_continuous_campaign_reference_documents_only_emitted_actions(): + repo_root = Path(__file__).parents[3] + reference = repo_root / "skills" / "nvflare-autofl" / "references" / "continuous-campaigns.md" + text = reference.read_text(encoding="utf-8") + actions = { + "repair_baseline", + "edit_candidate", + "propose_candidate", + "submit_baseline", + "submit_candidate", + "rerun_with_escalated_execution", + "run_literature_loop", + "final_report", + } + + assert all(f"`{action}`" in text for action in actions) + assert "`evaluate_candidate`" not in text diff --git a/tests/unit_test/tool/autofl_skill_plot_progress_test.py b/tests/unit_test/tool/autofl_skill_plot_progress_test.py index 2cb59a2f0b..eefaa47987 100644 --- a/tests/unit_test/tool/autofl_skill_plot_progress_test.py +++ b/tests/unit_test/tool/autofl_skill_plot_progress_test.py @@ -109,6 +109,16 @@ def test_load_results_uses_productized_ledger_fields(tmp_path): assert plotter.normalize_records(records) == records +def test_zero_score_label_uses_the_actual_score(): + plotter = _load_plotter() + record = _record(plotter, 1, 0.0) + + offset, _, vertical_alignment = plotter.label_placement(0, record, (0.0, 2.0), (-1.0, 0.1)) + + assert offset[1] < 0 + assert vertical_alignment == "top" + + def test_rich_progress_plot_renders_png(tmp_path): pytest.importorskip("matplotlib") plotter = _load_plotter() diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index db0cf57685..62fdf30bcc 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -37,7 +37,7 @@ def _load_runner(): def _campaign_config(): return { "schema_version": "nvflare.autofl.config.v1", - "import": {"source_sha256": "a" * 64}, + "import": {"source_sha256": "a" * 64, "support": {"status": "supported"}}, "job": {"allowed_edit_paths": ["job.py", "client.py"]}, "objective": { "metric": "accuracy", @@ -99,6 +99,21 @@ def test_candidate_run_args_cannot_override_fixed_budget(): assert runner.candidate_preserves_fixed_args(["--lr", "0.01"], config, {}) == (True, "") assert runner.candidate_preserves_fixed_args(["--num_rounds=2"], config, {})[0] is False assert runner.candidate_preserves_fixed_args(["--n-clients", "4"], config, {})[0] is False + assert runner.candidate_preserves_fixed_args(["--num_round", "2"], config, {})[0] is False + with pytest.raises(ValueError, match="canonical long options"): + runner.candidate_preserves_fixed_args(["-r", "2"], config, {}) + assert runner.candidate_preserves_fixed_args(["--temperature", "-1.5"], config, {}) == (True, "") + assert runner.candidate_preserves_fixed_args(["--new-algorithm", "fednova"], config, {}) == (True, "") + + +def test_supported_flags_are_exact_help_tokens(): + runner = _load_runner() + help_text = "usage: job.py [--num_rounds NUM_ROUNDS] [--name NAME]" + + assert runner.supports_flag(help_text, "--num_rounds") + assert runner.supports_flag(help_text, "--name") + assert not runner.supports_flag(help_text, "--num_round") + assert not runner.supports_flag(help_text, "--round") def test_candidate_plan_skips_out_of_bounds_learning_rate(): @@ -134,6 +149,14 @@ def test_runner_prefers_explicit_test_accuracy_alias(tmp_path): assert runner.extract_score(tmp_path, ["test_accuracy", "accuracy"]) == 0.8 +def test_metric_order_precedes_artifact_file_order(tmp_path): + runner = _load_runner() + tmp_path.joinpath("metrics_summary.json").write_text(json.dumps({"accuracy": 0.7}), encoding="utf-8") + tmp_path.joinpath("cross_val_results.json").write_text(json.dumps({"test_accuracy": 0.8}), encoding="utf-8") + + assert runner.extract_score(tmp_path, ["test_accuracy", "accuracy"]) == 0.8 + + def test_runner_applies_schema_metric_contract(): runner = _load_runner() config = { @@ -203,6 +226,130 @@ def test_run_streams_output_before_timeout(tmp_path): assert "TIMEOUT after 1s" in log_text +def test_run_streams_partial_line_before_timeout(tmp_path): + runner = _load_runner() + log_path = tmp_path / "run.log" + + rc, output, runtime = runner.run( + [ + sys.executable, + "-c", + "import sys, time; sys.stdout.write('partial output'); sys.stdout.flush(); time.sleep(30)", + ], + tmp_path, + timeout=1, + log_path=log_path, + ) + + assert rc == 124 + assert runtime < 5 + assert "partial output" in output + assert "partial output" in log_path.read_text(encoding="utf-8") + + +def test_metric_extraction_prefers_structured_artifacts_then_known_text_logs(tmp_path): + runner = _load_runner() + nested = tmp_path / "server" + nested.mkdir() + nested.joinpath("metrics_summary.json").write_text(json.dumps({"accuracy": 0.7}), encoding="utf-8") + nested.joinpath("cross_val_results.json").write_text(json.dumps({"accuracy": 0.8}), encoding="utf-8") + nested.joinpath("log_fl.txt").write_text("accuracy: 0.9\n", encoding="utf-8") + tmp_path.joinpath("run.log").write_text("accuracy: 0.95\n", encoding="utf-8") + + assert runner.extract_score(tmp_path, ["accuracy"]) == 0.7 + nested.joinpath("metrics_summary.json").unlink() + assert runner.extract_score(tmp_path, ["accuracy"]) == 0.8 + nested.joinpath("cross_val_results.json").unlink() + assert runner.extract_score(tmp_path, ["accuracy"]) == 0.95 + + +def test_result_paths_and_static_job_names_are_resolved_deterministically(tmp_path): + runner = _load_runner() + relative = tmp_path / "relative-result" + relative.mkdir() + config = { + "job": { + "recipe_args": {"name": {"value": "recipe-name", "confidence": "high"}}, + "fed_job_args": {"name": {"value": "fed-job-name", "confidence": "high"}}, + } + } + + assert runner.extract_result_dir("Result can be found in : relative-result", tmp_path) == relative.resolve() + assert runner.imported_job_names(config) == ["recipe-name", "fed-job-name"] + assert runner.expected_simulator_roots(config, "injected") == [ + Path("/tmp/nvflare/simulation/injected"), + Path("/tmp/nvflare/simulation/recipe-name"), + Path("/tmp/nvflare/simulation/fed-job-name"), + ] + config["job"]["recipe_args"]["name"]["confidence"] = "low" + assert runner.imported_job_names(config) == ["fed-job-name"] + with pytest.raises(ValueError, match="unsafe simulator job name"): + runner.expected_simulator_roots({}, "../other-run") + + +def test_success_without_deterministic_result_root_is_actionable_failure(tmp_path, monkeypatch): + runner = _load_runner() + job = tmp_path / "job.py" + job.write_text("print('done')\n", encoding="utf-8") + monkeypatch.setattr(runner, "run_allow_timeout", lambda *args, **kwargs: (0, "done\n", 0.1)) + + record = runner.run_job( + runner.JobRun("candidate", [], "candidate"), + python=sys.executable, + job=job, + cwd=tmp_path, + help_text="", + fixed_args=[], + base_args=[], + output_root=tmp_path / "runs", + timeout=10, + simulator_no_progress_timeout=0, + metrics=["accuracy"], + config={"job": {}}, + ) + + assert record.status == "crash" + assert "no deterministic NVFlare result directory" in record.failure_reason + + +def test_run_job_collects_relative_result_and_standard_nvflare_text_metric(tmp_path): + runner = _load_runner() + job = tmp_path / "job.py" + job.write_text( + """ +from pathlib import Path + +result = Path("relative-result") +server = result / "server" +server.mkdir(parents=True, exist_ok=True) +server.joinpath("log.txt").write_text("accuracy: 0.76\\n") +print("Result can be found in : relative-result") +""".lstrip(), + encoding="utf-8", + ) + + record = runner.run_job( + runner.JobRun("candidate", [], "candidate"), + python=sys.executable, + job=job, + cwd=tmp_path, + help_text="", + fixed_args=[], + base_args=[], + output_root=tmp_path / "runs", + timeout=10, + simulator_no_progress_timeout=0, + metrics=["accuracy"], + config={"job": {}}, + ) + + assert record.status == "candidate" + assert record.score == pytest.approx(0.76) + assert tmp_path.joinpath("runs/candidate/simulation/server/log.txt").is_file() + assert tmp_path.joinpath("runs/candidate/run.log").is_file() + assert tmp_path.joinpath("relative-result").is_dir() + + def test_run_stops_on_nvflare_simulator_stall_log(tmp_path): runner = _load_runner() log_path = tmp_path / "run.log" @@ -396,6 +543,16 @@ def test_runner_state_marks_infrastructure_retry_non_final(tmp_path): assert state["final_response_allowed"] is False +def test_baseline_crash_is_not_counted_as_candidate_attempt(): + runner = _load_runner() + records = [runner.RunRecord("crash", "baseline", None, 1.0, "none", "baseline", "python job.py", "/tmp/baseline")] + + assert runner.candidate_attempts(records) == 0 + assert runner.is_sandbox_socket_failure( + "PermissionError: [Errno 1] Operation not permitted in get_open_ports while calling s.bind(('', 0))" + ) + + def test_code_candidate_keeps_improvement_and_restores_discard_without_git(tmp_path, monkeypatch): runner = _load_runner() job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) @@ -539,6 +696,24 @@ def fail_job_help(*args, **kwargs): assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" +def test_keyboard_interrupt_during_candidate_import_restores_workspace(tmp_path, monkeypatch): + runner = _load_runner() + job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + assert runner.main(["prepare", str(job), "--name", "interrupt", "--hypothesis", "change code"]) == 0 + draft = tmp_path / ".nvflare" / "autofl" / "candidates" / "interrupt" / "source" + draft.joinpath("client.py").write_text("ALGORITHM = 'candidate'\n", encoding="utf-8") + + def interrupt_import(*args, **kwargs): + raise KeyboardInterrupt + + monkeypatch.setattr(runner, "import_job_config", interrupt_import) + args = runner.parse_args(["evaluate", str(job)]) + with pytest.raises(KeyboardInterrupt): + runner.evaluate_candidate(args, job) + + assert client.read_text(encoding="utf-8") == "ALGORITHM = 'baseline'\n" + + def test_candidate_finalization_failure_rolls_back_workspace_and_campaign_files(tmp_path, monkeypatch): runner = _load_runner() job, client, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) @@ -673,6 +848,52 @@ def test_abandon_candidate_clears_pending_draft_without_touching_best(tmp_path, assert state["pending_candidate_manifest"] is None +def test_status_rescans_pending_manifests_before_writing_state(tmp_path, monkeypatch): + runner = _load_runner() + job, _, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + assert runner.main(["prepare", str(job), "--name", "pending", "--hypothesis", "change code"]) == 0 + state_path = tmp_path / ".nvflare" / "autofl" / "campaign_state.json" + state_path.write_text('{"final_response_allowed": true}\n', encoding="utf-8") + + assert runner.main(["status", str(job)]) == 0 + + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["reason"] == "pending_candidates" + assert state["next_action"] == "edit_candidate" + assert state["final_response_allowed"] is False + assert state["pending_candidate_manifest"].endswith("pending/candidate_manifest.json") + + +def test_status_refuses_malformed_candidate_manifest(tmp_path, monkeypatch): + runner = _load_runner() + job, _, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + assert runner.main(["prepare", str(job), "--name", "malformed", "--hypothesis", "change code"]) == 0 + manifest = tmp_path / ".nvflare" / "autofl" / "candidates" / "malformed" / "candidate_manifest.json" + manifest.write_text("not json\n", encoding="utf-8") + + assert runner.main(["status", str(job)]) == 2 + + +def test_status_uses_persisted_custom_stop_file_in_job_directory(tmp_path, monkeypatch): + runner = _load_runner() + job, _, _ = _initialize_fake_campaign(runner, tmp_path, monkeypatch) + metadata_path = tmp_path / ".nvflare" / "autofl" / "campaign.json" + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + metadata["settings"]["stop_file"] = ["CUSTOM_STOP"] + metadata_path.write_text(json.dumps(metadata), encoding="utf-8") + tmp_path.joinpath("STOP_AUTOFL").touch() + + assert runner.main(["status", str(job)]) == 0 + default_state = json.loads(tmp_path.joinpath(".nvflare/autofl/campaign_state.json").read_text(encoding="utf-8")) + assert default_state["reason"] == "continue" + + tmp_path.joinpath("CUSTOM_STOP").touch() + assert runner.main(["status", str(job)]) == 0 + custom_state = json.loads(tmp_path.joinpath(".nvflare/autofl/campaign_state.json").read_text(encoding="utf-8")) + assert custom_state["reason"] == "manual_stop_file" + assert custom_state["final_response_allowed"] is True + + def test_initialize_retries_an_unscored_baseline(tmp_path, monkeypatch): runner = _load_runner() job = tmp_path / "job.py" @@ -685,7 +906,7 @@ def test_initialize_retries_an_unscored_baseline(tmp_path, monkeypatch): def fake_run(run_def, **kwargs): return runner.RunRecord( "baseline", - "baseline", + run_def.name, next(scores), 1.0, "none", @@ -699,7 +920,10 @@ def fake_run(run_def, **kwargs): assert runner.main(command) == 1 assert runner.main(command) == 0 records = runner.load_results(tmp_path / "results.tsv") - assert [(record.status, record.score) for record in records] == [("baseline", 0.5)] + assert [(record.status, record.score) for record in records] == [("baseline", None), ("baseline", 0.5)] + assert [record.name for record in records] == ["baseline", "baseline_retry_2"] + metadata = json.loads(tmp_path.joinpath(".nvflare/autofl/campaign.json").read_text(encoding="utf-8")) + assert metadata["best_candidate"] == "baseline_retry_2" def test_record_literature_checkpoint_returns_to_agent_proposal(tmp_path, monkeypatch): @@ -737,6 +961,10 @@ def test_external_candidate_uses_standard_job_result_recording(tmp_path, monkeyp manifest_path = tmp_path / ".nvflare" / "autofl" / "candidates" / "prod_algo" / "candidate_manifest.json" assert json.loads(manifest_path.read_text(encoding="utf-8"))["status"] == "ready_for_external_execution" + assert ( + json.loads(tmp_path.joinpath(".nvflare/autofl/campaign_state.json").read_text(encoding="utf-8"))["next_action"] + == "submit_candidate" + ) assert ( runner.main( [ @@ -794,6 +1022,36 @@ def fake_run(command, cwd, timeout, log_path): assert captured["command"][mode_index + 1] == "min" +@pytest.mark.parametrize( + "config,expected", + [ + ( + { + "import": {"support": {"status": "partial"}}, + "budget": {"fixed_training_budget": {"num_rounds": 1}}, + }, + "job surface", + ), + ( + {"import": {"support": {"status": "supported"}}, "budget": {}}, + "fixed comparison budget", + ), + ( + { + "import": {"support": {"status": "supported"}}, + "budget": {"fixed_training_budget": {"num_rounds": 1}}, + "unresolved": [{"field": "budget.fixed_training_budget.num_clients", "reason": "dynamic"}], + }, + "safety-critical fields", + ), + ], +) +def test_campaign_admission_rejects_unresolved_safety_contract(config, expected): + runner = _load_runner() + + assert expected in "; ".join(runner.campaign_admission_errors(config)) + + def test_cli_lifecycle_runs_agent_code_candidate_end_to_end(tmp_path): repo_root = Path(__file__).parents[3] runner_path = repo_root / "skills" / "nvflare-autofl" / "scripts" / "run_job_campaign.py" From fa82a4113b89a37fef41aa137b4240a55cdcaaf9 Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Thu, 2 Jul 2026 12:53:44 -0400 Subject: [PATCH 22/23] Validate Auto-FL timeout and metric fallbacks Signed-off-by: Holger Roth --- .../scripts/run_job_campaign.py | 31 ++++++++++++++----- .../tool/autofl_skill_runner_test.py | 28 +++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index 5241ecd63d..b5edbf02c0 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -995,9 +995,10 @@ def extract_score(artifact_root: Path, metrics: Sequence[str] | str) -> Optional except Exception: continue for metric in metric_order: - number_patterns = [rf"{re.escape(metric)}[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)"] + number = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?" + number_patterns = [rf"{re.escape(metric)}[^0-9+\-.]+({number})"] if metric == "accuracy": - number_patterns.append(r"Accuracy of the network[^0-9+\-.]+([+-]?[0-9]+(?:\.[0-9]+)?)") + number_patterns.append(rf"Accuracy of the network[^0-9+\-.]+({number})") for text in texts: for pattern in number_patterns: matches = re.findall(pattern, text, flags=re.IGNORECASE) @@ -1899,17 +1900,31 @@ def restore_campaign_settings(args: argparse.Namespace, metadata: Dict[str, Any] def campaign_timeout(args: argparse.Namespace, schema: Dict[str, Any]) -> Tuple[int, int]: budget = comparison_budget(schema) - budget_timeout = budget.get("run_timeout_seconds") - timeout = max(args.timeout, int(budget_timeout)) if budget_timeout is not None else args.timeout - budget_no_progress_timeout = budget.get("simulator_no_progress_timeout_seconds") + budget_timeout = parse_timeout_setting(budget.get("run_timeout_seconds"), "run_timeout_seconds") + timeout = max(args.timeout, budget_timeout) if budget_timeout is not None else args.timeout + budget_no_progress_timeout = parse_timeout_setting( + budget.get("simulator_no_progress_timeout_seconds"), "simulator_no_progress_timeout_seconds" + ) no_progress_timeout = ( - int(budget_no_progress_timeout) - if budget_no_progress_timeout is not None - else args.simulator_no_progress_timeout + budget_no_progress_timeout if budget_no_progress_timeout is not None else args.simulator_no_progress_timeout ) return timeout, no_progress_timeout +def parse_timeout_setting(value: Any, name: str) -> Optional[int]: + if value is None: + return None + if isinstance(value, bool) or (isinstance(value, float) and not value.is_integer()): + raise ValueError(f"{name} must be a non-negative integer") + try: + parsed = int(value) + except (TypeError, ValueError) as e: + raise ValueError(f"{name} must be a non-negative integer") from e + if parsed < 0: + raise ValueError(f"{name} must be a non-negative integer") + return parsed + + def import_job_config( args: argparse.Namespace, job: Path, diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index 62fdf30bcc..91c9e147ec 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -157,6 +157,13 @@ def test_metric_order_precedes_artifact_file_order(tmp_path): assert runner.extract_score(tmp_path, ["test_accuracy", "accuracy"]) == 0.8 +def test_text_metric_extraction_supports_scientific_notation(tmp_path): + runner = _load_runner() + tmp_path.joinpath("log_fl.txt").write_text("validation_loss: 1.25e-4\n", encoding="utf-8") + + assert runner.extract_score(tmp_path, ["validation_loss"]) == pytest.approx(0.000125) + + def test_runner_applies_schema_metric_contract(): runner = _load_runner() config = { @@ -204,6 +211,27 @@ def test_comparison_budget_suppresses_duplicate_imported_fixed_budget_args(): assert runner.build_base_args(args, help_text, schema) == ["--n_clients", "8", "--num_rounds", "20"] +@pytest.mark.parametrize( + "field,value", + [ + ("run_timeout_seconds", [120]), + ("run_timeout_seconds", {"seconds": 120}), + ("simulator_no_progress_timeout_seconds", [120]), + ("simulator_no_progress_timeout_seconds", {"seconds": 120}), + ("run_timeout_seconds", -1), + ("run_timeout_seconds", True), + ("run_timeout_seconds", 1.5), + ], +) +def test_campaign_timeout_rejects_malformed_schema_values(field, value): + runner = _load_runner() + args = SimpleNamespace(timeout=900, simulator_no_progress_timeout=240) + schema = {"comparison_budget_args": {"default_candidate_budget": {field: value}}} + + with pytest.raises(ValueError, match=f"{field} must be a non-negative integer"): + runner.campaign_timeout(args, schema) + + def test_run_streams_output_before_timeout(tmp_path): runner = _load_runner() log_path = tmp_path / "run.log" From 9828fe4a4ad9d6b0e13b34bc5d3013e065b651aa Mon Sep 17 00:00:00 2001 From: Holger Roth Date: Thu, 2 Jul 2026 17:46:42 -0400 Subject: [PATCH 23/23] Enable Auto-FL server aggregator exploration --- docs/design/autofl_skill.md | 15 +++- docs/user_guide/nvflare_cli/autofl_skill.rst | 13 +++ skills/nvflare-autofl/SKILL.md | 14 ++- .../references/continuous-campaigns.md | 9 +- .../nvflare-autofl/scripts/campaign_guard.py | 4 +- .../scripts/run_job_campaign.py | 62 +++++++++++++ .../tool/autofl_skill_campaign_guard_test.py | 2 + .../tool/autofl_skill_runner_test.py | 88 +++++++++++++++++++ 8 files changed, 200 insertions(+), 7 deletions(-) diff --git a/docs/design/autofl_skill.md b/docs/design/autofl_skill.md index ad94e8eb44..48bf67ee7e 100644 --- a/docs/design/autofl_skill.md +++ b/docs/design/autofl_skill.md @@ -47,6 +47,8 @@ layer: scripts. - Fixed-budget constraints that must remain comparable across candidates. - Allowed edit paths and files that are out of scope for the agent. +- Existing preferred source targets declared by task-local + `mutation_schema.yaml`, once they resolve inside the job workspace. - Allowed creation patterns for new Python modules under the job root. - Artifact, ledger, and report locations for the campaign. - Provenance and unresolved fields that need user review before safe execution. @@ -102,12 +104,19 @@ Every import result includes: The skill must present editable, unresolved, and allowed sections before it runs candidates. This is the core product guardrail: NVFlare makes the campaign reviewable and reproducible; the agent makes it interactive and exploratory. +During campaign initialization, the runner merges existing, workspace-local +`mutation_schema.yaml` `preferred_targets` into both allowed-edit lists. Missing, +symlinked, reserved, or out-of-workspace targets remain unresolved rather than +being silently authorized. ## Candidate Contract The agent, rather than the deterministic runner, owns search policy. It may change tunables, edit the imported job's allowed source files, or implement new -algorithms as Python modules. Each attempt starts from the retained best source +client and server algorithms as Python modules. This includes creating or +editing server aggregator modules and registering them through `job.py`; the +agent is not limited to pre-enumerated FedAvg, FedAvgM, FedAdam, FedOpt, or +SCAFFOLD choices. Each attempt starts from the retained best source in `.nvflare/autofl/candidates//source` and has a generated `candidate_manifest.json` containing its hypothesis, base candidate, run arguments, changed files, source and budget hashes, patch hash, artifacts, and @@ -124,6 +133,10 @@ standard NVFlare job lifecycle. The built-in parameter candidates are suggestion seeds only. They are returned as machine-readable hypotheses and arguments when requested, but are not the default search loop and are never executed without agent selection. +After each literature-triggered plateau, campaign state requests at least one +source-backed server aggregation candidate under the same comparison budget. +When the job contract makes that impossible, the agent records the reason in +the literature event instead of silently omitting aggregation exploration. ## Execution Model diff --git a/docs/user_guide/nvflare_cli/autofl_skill.rst b/docs/user_guide/nvflare_cli/autofl_skill.rst index 436b80ea6b..5a6e8761ac 100644 --- a/docs/user_guide/nvflare_cli/autofl_skill.rst +++ b/docs/user_guide/nvflare_cli/autofl_skill.rst @@ -71,6 +71,11 @@ things from ``autofl.yaml``: Python modules it may add under the job root, and environment or policy boundaries. +If ``mutation_schema.yaml`` declares ``preferred_targets``, initialization adds +existing targets under the job root to both allowed-edit lists. Missing, +symlinked, reserved, or out-of-workspace targets remain visible as unresolved +items instead of being silently enabled. + This makes the workflow feel native and reproducible: NVFlare owns the truth of the campaign settings and execution surfaces; the agent owns exploration within explicit constraints. @@ -110,6 +115,14 @@ best. Built-in tunable candidates are available through the helper's ``suggest`` action only as optional seeds; the agent remains free to implement new algorithms. +New algorithms include server aggregation logic. The agent may create a Python +aggregator module or edit an allowed existing one and register it through +``job.py``; it is not limited to the job's existing FedAvg, FedAvgM, FedAdam, +FedOpt, or SCAFFOLD choices. After a literature-triggered plateau, the workflow +requires at least one source-backed server aggregation candidate under the same +comparison budget, unless the job contract is incompatible and that reason is +recorded in the literature event. + The helper's ``status`` action rescans pending manifests, stop files, the candidate cap, and the ledger before refreshing the authoritative ``.nvflare/autofl/campaign_state.json``. The standalone ``campaign_guard.py`` diff --git a/skills/nvflare-autofl/SKILL.md b/skills/nvflare-autofl/SKILL.md index 33a955ae67..a977901bd3 100644 --- a/skills/nvflare-autofl/SKILL.md +++ b/skills/nvflare-autofl/SKILL.md @@ -103,6 +103,10 @@ specific fields before running candidates. - Edit existing files only through candidate drafts and within `job.allowed_edit_paths`. New Python modules may match `job.allowed_create_patterns` under the job root. +- Use existing `mutation_schema.yaml` `preferred_targets` only after the runner + reflects them in both allowed-edit lists; surface unresolved targets. +- You may create and register new Python server aggregators through `job.py`; + do not limit exploration to existing FedAvg/FedAvgM/FedAdam/FedOpt/SCAFFOLD choices. - Preserve `budget.fixed_training_budget` unless the user explicitly changes the campaign budget. - If the environment provides `PYTHON`, `VIRTUAL_ENV`, or a venv on `PATH`, @@ -129,7 +133,8 @@ specific fields before running candidates. 1. Inspect `autofl.yaml`, current best source, prior manifests, and results. 2. Form a concrete hypothesis. Use literature, framework knowledge, source - edits, new algorithms, or a fallback tunable suggestion as appropriate. + edits, new client or server algorithms, or a fallback tunable suggestion as + appropriate. 3. Prepare a candidate, edit its draft, and evaluate its manifest. 4. Let the helper validate paths and fixed-budget comparability, compute the patch hash, execute or materialize it, extract metrics, and keep or restore. @@ -157,9 +162,8 @@ an explicit candidate budget, continue the campaign until manually interrupted or blocked. Do not stop after the first baseline, first batch, first successful candidate, first kept improvement, first local commit, or first plateau checkpoint. Do not stop after a first sweep of tunables; broaden into -agent-authored code or literature-derived algorithm candidates. Progress -reports in uncapped mode must not be -phrased as "should I continue?" decisions; the answer is continue unless the +agent-authored code or literature-derived algorithm candidates. Uncapped progress +reports must not ask whether to continue; continue unless the user explicitly interrupts or the code-owned state permits finalization. Do not invent a replacement campaign or new objective after a recoverable failure. Keep the current campaign identity and artifacts coherent unless the @@ -180,6 +184,8 @@ Use `campaign_guard.py` only for read-only ledger diagnostics; it never updates authoritative campaign state. After a source-backed review, record it with the helper's `record --literature --hypothesis ""` action before preparing its candidate. +After every literature-triggered plateau, evaluate at least one source-backed +server aggregation candidate, or record why the job contract is incompatible. ## Stop Handling diff --git a/skills/nvflare-autofl/references/continuous-campaigns.md b/skills/nvflare-autofl/references/continuous-campaigns.md index 0105008e60..ac1cb527a8 100644 --- a/skills/nvflare-autofl/references/continuous-campaigns.md +++ b/skills/nvflare-autofl/references/continuous-campaigns.md @@ -47,7 +47,9 @@ Common next actions: repairing runtime permissions; do not count it as a candidate. - `run_literature_loop`: run a short source-backed literature pass, record a non-scored `literature` row when a ledger is available, then launch the next - compatible same-budget candidates. + compatible same-budget candidates. Include at least one source-backed server + aggregation candidate; if that is incompatible with the job contract, + record the reason in the literature event. - `final_report`: generate final artifacts only after the runner permits a final response. @@ -60,6 +62,11 @@ switch mode rather than stopping: broaden the search within `autofl.yaml`, run a literature-inspired proposal pass, implement a compatible algorithm change, or request deterministic tunable suggestions as seeds. +Server aggregation is an open code-search surface. The agent may create a new +Python aggregator module, edit an existing allowed aggregator module, and +register it through `job.py`; it must not limit exploration to the job's +pre-existing FedAvg, FedAvgM, FedAdam, FedOpt, or SCAFFOLD options. + ## Simulator Recovery For NVFLARE simulator runs, the server log can be quiet after it dispatches a diff --git a/skills/nvflare-autofl/scripts/campaign_guard.py b/skills/nvflare-autofl/scripts/campaign_guard.py index f5a668de68..8e0d977033 100644 --- a/skills/nvflare-autofl/scripts/campaign_guard.py +++ b/skills/nvflare-autofl/scripts/campaign_guard.py @@ -258,7 +258,8 @@ def guard_state_for_rows( elif next_action == "run_literature_loop": instruction = ( "Do not produce a final answer. Run the literature loop, record a literature event, " - "then launch source-backed candidates under the same comparison budget." + "then launch source-backed candidates under the same comparison budget. Include at least one " + "server aggregation candidate when compatible with the job contract; otherwise record why it is incompatible." ) else: instruction = "Do not produce a final answer. Propose and prepare the next same-budget candidate now." @@ -279,6 +280,7 @@ def guard_state_for_rows( "best_score": best_score(rows, mode), "stop_files": stop_file_hits, "plateau": plateau, + "required_exploration": "source_backed_server_aggregation" if next_action == "run_literature_loop" else None, "agent_instruction": instruction, } diff --git a/skills/nvflare-autofl/scripts/run_job_campaign.py b/skills/nvflare-autofl/scripts/run_job_campaign.py index b5edbf02c0..9cbec10c6d 100644 --- a/skills/nvflare-autofl/scripts/run_job_campaign.py +++ b/skills/nvflare-autofl/scripts/run_job_campaign.py @@ -1168,6 +1168,58 @@ def load_mutation_schema(cwd: Path) -> Dict[str, Any]: return read_yaml(path) +def apply_mutation_schema_contract(config: Dict[str, Any], schema: Dict[str, Any], workspace: Path) -> Dict[str, Any]: + preferred_targets = schema.get("preferred_targets") + if preferred_targets is None: + return config + if not isinstance(preferred_targets, list) or not all(isinstance(value, str) for value in preferred_targets): + raise ValueError("mutation_schema.yaml preferred_targets must be a list of paths") + + job = config.setdefault("job", {}) + trust_contract = config.setdefault("trust_contract", {}) + job_paths = job.setdefault("allowed_edit_paths", []) + trust_paths = trust_contract.setdefault("allowed_edit_paths", []) + if not isinstance(job_paths, list) or not isinstance(trust_paths, list): + raise ValueError("autofl.yaml allowed_edit_paths must be lists") + + resolved_targets = [] + unresolved_targets = [] + for target in preferred_targets: + try: + relative = safe_relative_path(workspace, target) + except ValueError as e: + unresolved_targets.append(_schema_target_issue(target, str(e))) + continue + path = workspace / relative + if path.is_symlink(): + unresolved_targets.append(_schema_target_issue(target, "preferred target is a symlink")) + continue + if not path.is_file(): + unresolved_targets.append( + _schema_target_issue(target, "preferred target is not an existing file under the job workspace") + ) + continue + resolved_targets.append(relative) + for values in (job_paths, trust_paths): + if relative not in values: + values.append(relative) + + job["preferred_targets"] = resolved_targets + trust_contract["preferred_targets"] = list(resolved_targets) + if unresolved_targets: + unresolved = config.setdefault("unresolved", []) + trust_unresolved = trust_contract.setdefault("unresolved", []) + if not isinstance(unresolved, list) or not isinstance(trust_unresolved, list): + raise ValueError("autofl.yaml unresolved fields must be lists") + unresolved.extend(unresolved_targets) + trust_unresolved.extend(dict(item) for item in unresolved_targets) + return config + + +def _schema_target_issue(target: str, reason: str) -> Dict[str, str]: + return {"field": "mutation_schema.preferred_targets", "reason": f"{target}: {reason}"} + + def comparison_budget(schema: Dict[str, Any]) -> Dict[str, Any]: comparison = schema.get("comparison_budget_args") if not isinstance(comparison, dict): @@ -1840,6 +1892,7 @@ def campaign_summary( "candidate_cap", "candidate_cap_source", "agent_instruction", + "required_exploration", ]: if key in state_payload: payload[key] = state_payload[key] @@ -1992,6 +2045,7 @@ def refresh_campaign_artifacts( pending_manifest: Optional[Path] = None, next_action: Optional[str] = None, reason: Optional[str] = None, + required_exploration: Optional[str] = None, ) -> Dict[str, Any]: pending_manifests = pending_candidate_manifests(paths["workspace"]) if pending_manifest is not None and pending_manifest not in pending_manifests: @@ -2017,6 +2071,12 @@ def refresh_campaign_artifacts( state["next_action"] = next_action state["reason"] = reason or next_action state["agent_instruction"] = f"Do not produce a final answer. Execute `{next_action}` for this campaign." + if required_exploration: + state["required_exploration"] = required_exploration + state["agent_instruction"] = ( + f"{state['agent_instruction']} Prepare and evaluate at least one source-backed server aggregation " + "candidate when compatible with the job contract; otherwise record why it is incompatible." + ) state.update( { "best_candidate": metadata.get("best_candidate"), @@ -2116,6 +2176,7 @@ def initialize_campaign(args: argparse.Namespace, job: Path) -> int: timeout, _ = campaign_timeout(args, schema) config = import_job_config(args, job, paths["autofl_yaml"], paths["output_root"] / "import.log", timeout) config = apply_metric_contract(config, args.metric, schema) + config = apply_mutation_schema_contract(config, schema, workspace) config.setdefault("job", {})["allowed_create_patterns"] = list(ALLOWED_CREATE_PATTERNS) config.setdefault("trust_contract", {})["allowed_create_patterns"] = list(ALLOWED_CREATE_PATTERNS) write_yaml(paths["autofl_yaml"], config) @@ -2644,6 +2705,7 @@ def record_external_result(args: argparse.Namespace, job: Path) -> int: metadata, next_action="propose_candidate", reason="literature_review_recorded", + required_exploration="source_backed_server_aggregation", ) print_campaign_result(paths, records, state, literature_event=name) return 0 diff --git a/tests/unit_test/tool/autofl_skill_campaign_guard_test.py b/tests/unit_test/tool/autofl_skill_campaign_guard_test.py index 484b6d3200..bf9a4c9096 100644 --- a/tests/unit_test/tool/autofl_skill_campaign_guard_test.py +++ b/tests/unit_test/tool/autofl_skill_campaign_guard_test.py @@ -102,6 +102,8 @@ def test_guard_routes_plateau_to_literature_without_finalizing(): assert state["next_action"] == "run_literature_loop" assert state["final_response_allowed"] is False assert state["best_score"] == 0.85 + assert state["required_exploration"] == "source_backed_server_aggregation" + assert "server aggregation candidate" in state["agent_instruction"] assert "Do not produce a final answer" in state["agent_instruction"] diff --git a/tests/unit_test/tool/autofl_skill_runner_test.py b/tests/unit_test/tool/autofl_skill_runner_test.py index 91c9e147ec..dcd36f9f00 100644 --- a/tests/unit_test/tool/autofl_skill_runner_test.py +++ b/tests/unit_test/tool/autofl_skill_runner_test.py @@ -129,6 +129,92 @@ def test_candidate_plan_skips_out_of_bounds_learning_rate(): assert "--lr 0.2" not in candidate_commands +def test_initialize_merges_existing_mutation_schema_preferred_targets_into_autofl(tmp_path, monkeypatch): + runner = _load_runner() + job = tmp_path / "job.py" + job.write_text("print('job')\n", encoding="utf-8") + tmp_path.joinpath("client.py").write_text("print('client')\n", encoding="utf-8") + tmp_path.joinpath("custom_aggregators.py").write_text("class CustomAggregator:\n pass\n", encoding="utf-8") + tmp_path.joinpath("mutation_schema.yaml").write_text( + "preferred_targets:\n - client.py\n - custom_aggregators.py\n", + encoding="utf-8", + ) + monkeypatch.setattr(runner, "import_job_config", lambda *args, **kwargs: deepcopy(_campaign_config())) + monkeypatch.setattr(runner, "job_help", lambda *args, **kwargs: "") + monkeypatch.setattr(runner, "write_progress", lambda path, *args: path.write_bytes(b"progress")) + monkeypatch.setattr( + runner, + "run_job", + lambda run_def, **kwargs: runner.RunRecord( + run_def.status, + run_def.name, + 0.5, + 1.0, + "none", + run_def.description, + "python job.py", + "/tmp/baseline", + ), + ) + + assert runner.main(["initialize", str(job), "--no-prefer-synthetic"]) == 0 + + config = runner.read_yaml(tmp_path / "autofl.yaml") + assert "custom_aggregators.py" in config["job"]["allowed_edit_paths"] + assert "custom_aggregators.py" in config["trust_contract"]["allowed_edit_paths"] + assert config["job"]["preferred_targets"] == ["client.py", "custom_aggregators.py"] + assert config["trust_contract"]["preferred_targets"] == ["client.py", "custom_aggregators.py"] + + assert ( + runner.main( + [ + "prepare", + str(job), + "--name", + "new_server_aggregator", + "--hypothesis", + "implement source-backed server aggregation", + ] + ) + == 0 + ) + draft = tmp_path / ".nvflare/autofl/candidates/new_server_aggregator/source/custom_aggregators.py" + draft.write_text("class CustomAggregator:\n improved = True\n", encoding="utf-8") + monkeypatch.setattr( + runner, + "run_job", + lambda run_def, **kwargs: runner.RunRecord( + "candidate", + run_def.name, + 0.8, + 1.0, + "none", + run_def.description, + "python job.py", + "/tmp/candidate", + ), + ) + + assert runner.main(["evaluate", str(job)]) == 0 + assert "improved = True" in tmp_path.joinpath("custom_aggregators.py").read_text(encoding="utf-8") + + +def test_missing_or_escaping_preferred_targets_remain_unresolved(tmp_path): + runner = _load_runner() + config = deepcopy(_campaign_config()) + schema = {"preferred_targets": ["missing_aggregator.py", "../shared/custom_aggregators.py"]} + + updated = runner.apply_mutation_schema_contract(config, schema, tmp_path) + + assert "missing_aggregator.py" not in updated["job"]["allowed_edit_paths"] + assert "../shared/custom_aggregators.py" not in updated["job"]["allowed_edit_paths"] + unresolved_reasons = [ + item["reason"] for item in updated["unresolved"] if item["field"] == "mutation_schema.preferred_targets" + ] + assert any(reason.startswith("missing_aggregator.py:") for reason in unresolved_reasons) + assert any(reason.startswith("../shared/custom_aggregators.py:") for reason in unresolved_reasons) + + def test_runner_prefers_explicit_test_accuracy_alias(tmp_path): runner = _load_runner() result_path = tmp_path / "cross_val_results.json" @@ -975,6 +1061,8 @@ def test_record_literature_checkpoint_returns_to_agent_proposal(tmp_path, monkey assert records[-1].diff_summary == "reviewed adaptive federated optimization" state = json.loads(tmp_path.joinpath(".nvflare/autofl/campaign_state.json").read_text(encoding="utf-8")) assert state["next_action"] == "propose_candidate" + assert state["required_exploration"] == "source_backed_server_aggregation" + assert "server aggregation candidate" in state["agent_instruction"] def test_external_candidate_uses_standard_job_result_recording(tmp_path, monkeypatch):

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�tw{h1X6=0FkCWINF? zfi2Bh*C$lEP0Kd`T1Pvy24 z;zLve^XT4T55%lJlDmf0QSWxfZ;XF9$cf=R-9tvJQ%H8@;^Q^YDmoqeFg;Hwo;2be zdgp9AWANlwPy0!ALy70e`ze8@nqG+T%)fp5@7@K|rvT|=9sjmO?avRix!|v5Hc;Rf zp5xLt+a7-kagvk~)VqL-0GE@%O}{%az=77_-!Z8u*2R4`Bki@(XL2sCEh#E3Wz{41 zqP?t}k39ODr%|-i1$ucy#wWP(-|OVhQcIkyiSQq359lke5ad%4goXPlk{s?kQPF8W5>kt9 zv;I|P=!;j~p6Fsb@Aui(zgP4d?e_$D?(%NFJXphgNtpq|z~K+ly&EYQfiU?_MY3#9 z5LvAOk~AL3*;4)0DB5o(21$)IzqR9i_pT%9&V{URuH2uNG`+o${4@fR7{6y=xzBBI zV^JS7<}G*~tLYl6H;P`#eWrqSf)a!>d9$CEAflMppk1y~Y2s>28{w?x}ryG1qFmHemspF|4A7 z(sePHlwAkG$iyVCwOX=0U}_C&&ut~pWeIFzy&5XM$cG=YZ z9VN(XOw0_HRe?TZ_;_O>?Ct&nvL6&5dvkzEn~7(NzReW`Q1|-Gc%a7y6$Vs6(0tn$ zr%?SRXLxruVp29Y^)KbmKRwO=!y#BU{mJpFi+gKD{pMV!>mPei)#$-AwJPp$iNB~! zvel7VIS2%5;J-~Xwreau4lgHQm*{HNwI>oz!Tmaqa+`5|=fvZ}>t6cGwbES`xJ4eQ zTQ>n^>cmWr{Yo|@4S>oOWHSO@|4@7EO8@{_`<3AT-1V@!jU?Ha=x{%IEN5WQvMLBV zN04P)GWuqH_Ut9nwy9psVt#85sO~8FtPn6kV@)iRWTFO=hM_Oaaax-o&|djw+ftK@ zrXHLEmSC&v!B+-d5u-C_IGlO%5*ptt-kuYPEv*e-TfA8^eH6*D#4vLk{7W|3$phDp zp`8yrOKay(@NRP!9m`A<^yC!csc0`@-k&e8&$dGsI5O+m(pFTfX0&**$G(*CpRL8o zZ{B9W>(6Vi+!+aQT`t87rK|t!7xzxxvXXdb6p2*JF7!BbflE!oq?GY0-vZKpoz{Ob za99kL?B)?ODUE?o_VTJdI+&{I409ZFzVqO9@DUL&lX+qABRGP7^%Yn*-_N+VC0SIP z9Tv-rjU)WtOA+gT>lWhHdxZFdOU=h}BVmLq0d~Ww*HceFldxTn5uw!Upq`?O@5Wx8 zNia42^@>|(wm|rAr-;7;BmSr2>Q2g;3^5w_YuMHs)Ind#ISnsiy%@31UR9Xk709l$ zTF5MRm@kBe14}7d1mq1SN~jgWFgm-%5YbCjA01-W0CgXG zeUTN>_RTZg%;XVbcgYfY5wTlC=mCagT~na~6rq4s~j;cEc;80#OxuydR3>YaOthyDI$FYAWO)-5H-qS=QQ}*KO7AS=fIuk$q zunz%Kt+yb`rp~T9a;eZ{^pDd4S{TC9G`q4W3>0VgjCbGb?z(k8C18L z-GA2*m!oE8#jkPs?RNUTC-7%n+=w*)iMXqz_g^Mv&77RJ_O8HT3CnD_W` zxzh=#coAgTHC5a;&T#chanzGTU^8(1=X@QFCFUw?K9xSEC@OS=aOJ)i_RM+p^