Generated banner for Nvflare Autofl
Generated logo for Nvflare Autofl

Nvflare Autofl

NVIDIAOfficialCreated by NVIDIA FLARE Team

  • Skill
  • Workflow
  • Software development
  • Apache-2.0
  • Medium risk
  • Has scripts

From the author

Use for agent-assisted Auto-FL optimization of an existing NVFLARE job in simulation, POC, or production. Do not use for code conversion, diagnosis-only work, or deployment setup.
— the description by NVIDIA FLARE Team

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

NVFLARE Auto-FL provides a structured, agent-assisted workflow for optimizing existing NVFLARE jobs across simulation, POC, and production environments. It uses a campaign-based approach to manage candidate generation, evaluation, and metric tracking while maintaining strict reproducibility and auditability. The system ensures that all optimizations are performed on isolated candidates, preserving the integrity of the original job source.

What it does

  • Initializes optimization campaigns for existing NVFLARE jobs.
  • Manages the lifecycle of candidate generation and evaluation.
  • Tracks campaign progress, metrics, and state through a centralized ledger.
  • Supports both bounded and continuous optimization strategies.
  • Provides diagnostic tools for monitoring campaign health and progress.
  • Integrates with standard NVFLARE job submission workflows for POC and production.

When to use it

  • When you need to optimize hyperparameters or code for an existing NVFLARE job.
  • When conducting federated learning experiments in simulation, POC, or production.
  • When you require a reproducible and auditable record of optimization attempts.
  • When managing long-running or complex optimization campaigns.

Advantages

  • Ensures isolated, reproducible candidate changes.
  • Preserves the original job source and state.
  • Provides clear accounting of budget and candidate attempts.
  • Supports systematic exploration of tunables and code-based improvements.
  • Maintains audit trails for all optimization decisions.

Usage examples

“Initialize an optimization campaign for my job.py in simulation with accuracy as the metric.”
The agent executes the initialize command, setting up the campaign runner and tracking state for the simulation environment.
“Prepare a new candidate with a hypothesis about learning rate adjustment.”
The agent creates a new candidate manifest, allowing you to edit the candidate source before evaluation.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/nvidia/nvflare-autofl/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip nvflare-autofl.zip -d .agents/skills

Documentation by NVIDIA FLARE Team

NVFLARE Auto-FL

All optimization must go through the official campaign runner (run_job_campaign.py): never optimize or edit the user's project directly outside a prepared candidate.

Purpose

Improve a measured objective for an existing NVFLARE job through isolated, reproducible candidate changes while the campaign runner preserves the live best source, comparison budget, metric semantics, state, and evidence.

Available Scripts

ScriptPurposeArguments
scripts/run_job_campaign.pyAuthoritative campaign lifecycle runnerACTION JOB plus action-specific flags
scripts/campaign_guard.pyRead-only ledger diagnostics[RESULTS], --mode, and diagnostic thresholds
scripts/plot_progress.pyRender campaign progress[RESULTS], --output, --metric, --mode
scripts/job_importer.pyImport library used by the campaign runnerNot a standalone CLI

Run the bundled CLIs directly with Python. This skill has no NVFLARE or agent run_script() helper; do not invent or call one. Resolve each script relative to this SKILL.md.

Inputs

  • Required: an existing NVFLARE job.py, the optimization objective or metric, and sim, poc, or prod.

  • Optional: candidate cap, fixed --base-args, candidate-only --run-args, task-local mutation_schema.yaml, and declared simulator environment-variable names.

  • Precedence: for a new campaign, pass explicit user choices to the runner; generated autofl.yaml and state then become authoritative. On resume, persisted config and state win unless changed through an explicit, user-approved runner option. Never infer a cap from ambient variables.

Instructions

Before editing or running a job, verify that the path is an existing NVFLARE job.py and the user requested an optimization objective. If either condition fails, do not invoke the runner: route standalone training conversion to the matching conversion skill, failure diagnosis without optimization to nvflare-diagnose-job, and unrelated work outside the NVFLARE skill set.

Resolve run_job_campaign.py relative to this SKILL.md, store its absolute path as RUNNER, and initialize the campaign:

python "$RUNNER" initialize ./job.py [--metric <metric>] --env <sim|poc|prod> [--max-candidates <n>]

Campaign direction comes from job.py key_metric_mode or a same-metric stop_cond; NVFLARE defaults to max. Declare raw loss metrics with key_metric_mode="min"; explicitly negated metrics remain ordinary max metrics. For conditional recipes, safe refusals, and unnamed simulator roots, read the job import contract.

Read autofl.yaml and the JSON response, then prepare an agent-authored candidate with a short hypothesis and optional candidate-only arguments:

python "$RUNNER" prepare ./job.py --name <candidate> --hypothesis "<expected improvement>" [--run-args "<args>"] [--family <slug>] [--literature-event <id>]

Pass --family <slug> and --literature-event <id> when the candidate develops a recorded literature review; both persist in the manifest and as ledger columns.

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. Then evaluate:

python "$RUNNER" evaluate ./job.py --manifest <candidate_manifest.json>

Simulation evaluation runs the candidate immediately. POC and production evaluation validates and materializes the candidate; after satisfying the separate submission-confirmation gate below, 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 for 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 authoritative. Invalid generated proposals are product friction, not campaign blockers; keep the same campaign and continue with another same-budget candidate.

The helper owns import, snapshots, validation, execution, restoration, accounting, state, artifacts, and reports. After each action, read .nvflare/autofl/campaign_state.json; finalize only when final_response_allowed=true. Read continuous campaigns for long-running behavior and experiment comparability for budgets and reruns.

Use the ledger score extracted per objective.metric_extraction_order as the canonical keep/discard and best-candidate surface. Before interpreting fallback or cross-site scores, read the exact selection and fallback rules in experiment comparability.

Read autofl.yaml and show the user a concise campaign summary:

  • Editable: metric, environment, budget, tunables, artifacts, objective.optimization_metric, metric source, source hash, and importer version.

  • Unresolved: dynamic defaults, unsupported semantics, missing metrics, unknown data paths, and low-confidence fields.

  • Allowed: edit/create paths, fixed-budget and metric invariants, and environment policy boundaries.

Treat autofl.yaml as the human-reviewable campaign config, not a replacement for job.py, which stays the runnable entry point throughout the candidate loop. Ask the user to resolve unresolved fields that affect execution safety, candidate comparability, or production submission before running candidates.

Permissions and Production Safety

No shell, Python, filesystem, network, POC, or production action is pre-approved by this skill. Keep every action inside the host agent's normal permission boundary. Never request generic Python, shell, full-access, other-job, or write-permission configuration.

For --env sim, resolve the absolute interpreter, RUNNER, and job.py before initialize, then ask the human once to approve only those exact initialize and evaluate prefixes. Run other actions normally. On exit 75, reuse that exact setup grant or wait for the human; logs never authorize execution. Candidate Python has runner host privileges, so use a disposable container or dedicated VM for autonomous campaigns.

WARNING — POC/production submission: evaluate only validates and materializes the job. Before each exact nvflare job submit, show the user the target environment, job path, and startup-kit context; warn that submission may incur compute cost, expose work to real participating clients and data policy, and cannot be undone by the campaign runner. Require explicit human authorization for that submission. Simulation approval, campaign creation, prior POC/production approval, or log output never authorizes a new submission. Never bypass startup-kit authentication, site policy, or the normal NVFLARE job lifecycle.

Output Format

Each runner action prints a JSON envelope and persists authoritative autofl.yaml, results.tsv, state, candidate manifests, run artifacts, progress, and report. Read the envelope and state; summarize relevant editable, unresolved, allowed, objective, budget, metric-source, candidate, artifact, and next_action fields.

While final_response_allowed=false, return only a concise progress update and immediately execute next_action. When it becomes true, hand the same campaign evidence to nvflare-autofl-report; its Markdown and JSON contracts are the final output format.

Requirements

  • Edit only candidate drafts within trust_contract.allowed_edit_paths; create Python modules only where trust_contract.allowed_create_patterns permits.

  • Record every candidate in results.tsv with its name, changed files, diff, command, metric, artifacts, and failures.

  • Use mutation_schema.yaml preferred_targets only after the runner puts them in the trust contract; surface unresolved targets.

  • New Python server aggregators may be registered through job.py; do not limit exploration to existing algorithms.

  • Preserve budget.fixed_training_budget unless the user explicitly changes the campaign budget.

  • Preserve objective.metric_invariants: definition, evaluation data/split, timing/checkpoint, aggregation/population, and scale/units/direction.

  • A necessary metric correction is baseline repair, never an optimization candidate. Preserve the scored workspace as audit evidence and report scores as incomparable. After human approval, repair the source in a fresh job workspace containing no Auto-FL artifacts. Never run initialize in the scored workspace; it resumes old evidence.

  • Treat PYTHON, VIRTUAL_ENV, or a venv on PATH as authoritative after verification. Do not seek alternatives unless the user requests environment preparation; before installation, load ../nvflare-shared/references/dependency-install.md.

  • Use the configured SimEnv for simulation. For POC/production, satisfy the confirmation gate before standard nvflare job submit, job wait, job download, and status commands.

  • Prefer small, reviewable edits over broad rewrites.

  • Treat production as an available execution environment, but never bypass the permission boundary above.

Candidate Loop

  1. Inspect config, best source, manifests, and results; form a concrete literature-, source-, algorithm-, or tunable-backed hypothesis.

  2. Prepare a candidate, edit its draft, and evaluate its manifest.

  3. Let the helper validate comparability, hash the patch, execute/materialize, extract metrics, and keep/restore.

  4. Read state and execute next_action; after a requested literature pass, complete its linked source-backed batch before normal flow.

Continuous Campaign Rule

For uncapped campaigns, continue same-budget candidates until interrupted. A kept improvement, plot, report, commit, or plateau is a checkpoint. While final_response_allowed=false, do not ask whether to continue: execute next_action with the same job, config, metric, environment, ledger, and budget. See continuous campaigns for recovery.

Candidate Caps

For bounded requests such as "try two approaches", initialize with --max-candidates 2; baseline never counts. Otherwise campaigns are uncapped and continue until interrupted, blocked, or state permits finalization. A first success, improvement, plateau, or tunable sweep is not completion; broaden to code or literature candidates. Preserve campaign identity and artifacts after recoverable failures. See the bounded campaign example.

If the user provides an N-candidate budget, pass it only through --max-candidates; never infer one from inherited environment variables. It counts keep/discard/crash after baseline. Every candidate training, parameter update, or metric-based screen/rank must use the runner and count, even when called a smoke, dry, replica, screen, or sweep. Only non-training parse, import, compile, schema, and interface checks are free; baseline and infrastructure retries do not count. Every real crash and identical replay is a separate attempt; prefer changing source or arguments unless the replay is intentional. Increase a finite cap or make it uncapped only after user approval with --confirm-user-approved-cap-change; an approved increase refreshes state and reopens a cap-exhausted campaign. State reports the cap, remaining attempts, baseline, improvement, abandoned candidates, and accounting instruction; approved cap changes stay in campaign metadata.

Treat plateau as a decision checkpoint, not an automatic stop: summarize it 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 diagnostics; it never writes state. Both campaign_guard.py and plot_progress.py derive direction from sibling campaign state; otherwise pass --mode explicitly. After a source-backed review, record it with record --literature --hypothesis "<sources and decision>". Each review gets a persistent literature_event_id and requires an exploration batch before normal flow resumes: exploration_batch_size (default 3) scored source-backed candidates linked via prepare --literature-event <id> — a faithful implementation, a tuned variant, and an ablation. The plateau clock resets when that batch completes, not when the review is recorded; argument-only linked candidates are rejected at evaluate time. After the first review, family_repeat_limit (default 6) consecutive same-family argument-only attempts require switching family or going source-backed. Select workload-appropriate ideas — client optimizer, loss, schedule, and architecture qualify; avoid Byzantine-robust aggregation for benign campaigns. If no source-backed exploration is compatible, record why in the event. Flags, env vars, and full semantics: continuous-campaigns.md.

Examples

Use the initialization, preparation, and evaluation examples in Instructions. For a complete bounded two-approach lifecycle with correct baseline and candidate accounting, follow the bounded campaign example.

Limitations

  • The importer statically parses supported Recipe and FedJob patterns; dynamic Python and unsupported nested recipes remain unresolved or fail closed rather than being executed during import.

  • Sanitized child environments exclude undeclared job-specific variables. Declare only required names through environment.simulator_env_passthrough; values remain runtime-only.

  • Candidate source runs with the runner's host privileges. Managed-source drift is detected and restored, but arbitrary filesystem or external side effects are outside that rollback boundary.

  • A single or noisy campaign does not establish robustness. Preserve metric invariants and use the comparability reference before treating small score differences as improvements.

  • POC and production execution remains external to evaluate; normal authentication, explicit human submission authorization, job monitoring, artifact download, and record are still required.

Troubleshooting

On import or validation failure, fix the reported contract issue without bypassing the runner. On exit 75, reuse the exact approved prefix or wait for the human. For noisy scores, follow experiment comparability.

Stop Handling

Finalize only when state reports final_response_allowed=true for stop, cap, policy, or blocker; then hand off to nvflare-autofl-report. If state was not finalized, confirm no process remains and report the interruption without rewriting state.

Files

scripts/job_importer.py

# 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``.

Usage:
    Import this library through ``run_job_campaign.py``; it is not a standalone
    CLI and intentionally has no shebang or process exit-code contract.
Arguments:
    Public helpers accept a job source path plus optional static job arguments
    and requested campaign settings.
Output:
    Returns a reviewable Auto-FL configuration mapping; dynamic or unsupported
    fields are surfaced under ``unresolved``.

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``.
"""

from __future__ import annotations

import ast
import hashlib
import logging
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple

import yaml

logger = logging.getLogger(__name__)

_UNRESOLVED = object()
_core_looks_lower_is_better = _UNRESOLVED
_last_core_heuristic_warning = None

AUTOFL_CONFIG_SCHEMA_VERSION = "nvflare.autofl.config.v1"
IMPORTER_VERSION = "nvflare-autofl-job-importer/v1"
ALLOWED_CREATE_PATTERNS = ["**/*.py"]
METRIC_INVARIANTS = [
    "definition",
    "evaluation_data_and_split",
    "evaluation_timing_and_checkpoint",
    "aggregation_and_population",
    "scale_units_and_direction",
]
METRIC_CHANGE_POLICY = "restart_campaign_with_repaired_baseline"
SUPPORTED_OBJECTIVE_MODES = {"min", "max"}
LOWER_IS_BETTER_METRIC_SUBSTRINGS = ("loss", "err")
LOWER_IS_BETTER_METRIC_TOKENS = {
    "bce",
    "ce",
    "cer",
    "mae",
    "mse",
    "nll",
    "perplexity",
    "ppl",
    "rmse",
    "wer",
}
ALREADY_NEGATED_METRIC_TOKEN = "neg"

SUPPORTED_ENV_NAMES = {"PocEnv", "ProdEnv", "SimEnv"}
NON_OPTIMIZATION_RECIPE_NAMES = {"FedEvalRecipe", "FedStatsRecipe", "NumpyCrossSiteEvalRecipe"}
UNSUPPORTED_NESTED_RECIPE_NAMES = {"FlowerRecipe"}
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
    function_name: 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 AssignmentInfo:
    """Assignment value with the branch conditions required to reach it."""

    value: ast.AST
    branch_conditions: Tuple[Tuple[ast.AST, bool], ...] = ()


@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, Tuple[AssignmentInfo, ...]]
    source: str
    function_name: Optional[str] = None
    branch_conditions: Tuple[Tuple[ast.AST, bool], ...] = ()
    has_keyword_splat: bool = False
    has_positional_args: bool = False
    has_positional_splat: bool = False


class JobImportError(ValueError):
    """Raised when the importer cannot read or parse a job file."""


class _NoAliasSafeDumper(yaml.SafeDumper):
    def ignore_aliases(self, data):
        return True


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 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,
        target_env: Optional[str] = None,
        max_candidates: Optional[int] = None,
        job_args: Optional[Sequence[str]] = None,
    ) -> Dict[str, Any]:
        """Return an ``autofl.yaml``-shaped config for ``job_path``."""

        try:
            return self._import_job(
                job_path,
                metric=metric,
                target_env=target_env,
                max_candidates=max_candidates,
                job_args=job_args,
            )
        except JobImportError:
            raise
        except (OSError, UnicodeError, SyntaxError, RecursionError) as e:
            raise JobImportError(f"failed to parse {job_path}: {e}") from e

    def _import_job(
        self,
        job_path: str,
        *,
        metric: Optional[str] = None,
        target_env: Optional[str] = None,
        max_candidates: Optional[int] = None,
        job_args: Optional[Sequence[str]] = 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.
            target_env: Optional target environment, such as ``sim`` or ``prod``.
            max_candidates: Optional fixed candidate budget.
            job_args: Optional job CLI arguments used to resolve argparse defaults
                and simple conditional recipe branches.

        Returns:
            A deterministic, YAML-serializable mapping.
        """

        source_path = self._resolve_job_path(job_path)
        try:
            source_text = source_path.read_text(encoding="utf-8")
            tree = ast.parse(source_text, filename=str(source_path))
            index = _ImportIndex.from_tree(tree, source_text)
        except (OSError, UnicodeError, SyntaxError, RecursionError) as e:
            raise JobImportError(f"failed to parse {source_path}: {e}") from e

        parser_args, reachable_functions = _reachable_parser_args(tree, index, job_args or [])
        job_call = index.first_job_call(reachable_functions, parser_args)
        env_call = index.first_env_call(reachable_functions, parser_args)
        train_script = self._resolve_train_script(
            source_path, job_call, index, parser_args, source_text, reachable_functions
        )
        train_args = _collect_argparse_args_from_file(train_script) if train_script else {}
        unresolved: List[Dict[str, str]] = []

        if not job_call:
            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))
        support_status, support_reason = _support_status(job_call)
        if support_reason and job_call:
            unresolved.append(_unresolved("job.surface", support_reason))
        if not train_script:
            unresolved.append(_unresolved("job.train_script", "no train_script was found or resolved"))

        job_metric, job_metric_source, metric_issue = self._resolve_job_metric(job_call, parser_args, source_text)
        metric_name = metric or job_metric
        metric_source = "user_request" if metric else job_metric_source
        mode_name, mode_source, mode_issue = self._resolve_metric_mode(job_metric, job_call, parser_args, source_text)
        objective = _objective_contract(
            metric_name,
            metric_source,
            mode_name,
            mode_source,
            job_metric,
            job_metric_source,
        )
        if metric_issue:
            mode_uses_job_metric = mode_source == "job:stop_cond" or (
                job_call is not None and "stop_cond" in job_call.keywords and "key_metric_mode" not in job_call.keywords
            )
            if metric and not mode_issue and not mode_uses_job_metric:
                metric_issue = {**metric_issue, "field": "objective.job_key_metric"}
            unresolved.append(metric_issue)
        if mode_issue:
            unresolved.append(mode_issue)

        budget, budget_issues = self._resolve_budget(max_candidates, job_call, env_call, 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(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",
        }
        if job_call:
            call_args, call_issues = self._resolved_call_keywords(job_call, parser_args, source_text)
            unresolved.extend(call_issues)
            if _is_recipe_call(job_call):
                job_payload.update(
                    {
                        "recipe": job_call.name,
                        "recipe_class": job_call.full_name,
                        "recipe_args": call_args,
                    }
                )
            else:
                job_payload.update(
                    {
                        "fed_job": job_call.name,
                        "fed_job_class": 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, parser_args, source_text)
        if env_call:
            env_args, env_issues = self._resolved_call_keywords(env_call, 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": support_status,
                    "patterns": _support_patterns(job_call, env_call),
                    **({"reason": support_reason} if support_reason else {}),
                },
            },
            "job": job_payload,
            "objective": objective,
            "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,
                    objective,
                    search_space,
                ),
                "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")),
                    "must_report_candidate_diffs": True,
                },
            },
            "unresolved": list(unresolved),
        }
        return config

    dump_yaml = staticmethod(dump_autofl_yaml)

    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],
        index: "_ImportIndex",
        parser_args: Dict[str, ArgSpec],
        source_text: str,
        reachable_functions: Set[str],
    ) -> Optional[Path]:
        if not job_call:
            return None

        train_script_node = job_call.keywords.get("train_script")
        if train_script_node:
            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 self._existing_train_script(source_path, value)
            return None

        script_paths = set()
        for runner_call in index.script_runner_calls:
            if runner_call.function_name is not None and runner_call.function_name not in reachable_functions:
                continue
            script_node = runner_call.keywords.get("script")
            if not script_node:
                continue
            resolved = _resolve_value(script_node, runner_call.assignments, parser_args, source_text)
            if isinstance(resolved.value, str) and _is_resolved_path_string(resolved):
                path = self._existing_train_script(source_path, resolved.value)
                if path:
                    script_paths.add(path)
        return next(iter(script_paths)) if len(script_paths) == 1 else None

    def _existing_train_script(self, source_path: Path, value: str) -> Optional[Path]:
        path = (source_path.parent / value).resolve()
        try:
            path.relative_to(self.workspace_root)
        except ValueError:
            return None
        return _existing_path(path)

    def _resolve_job_metric(
        self,
        job_call: Optional[CallInfo],
        parser_args: Dict[str, ArgSpec],
        source_text: str,
    ) -> Tuple[str, str, Optional[Dict[str, str]]]:
        if job_call and job_call.has_positional_args:
            return (
                "accuracy",
                "default",
                _unresolved(
                    "objective.metric",
                    _positional_call_reason(
                        job_call,
                        "key_metric may be supplied positionally; use keyword-only constructor arguments",
                    ),
                ),
            )
        if job_call and job_call.has_keyword_splat and "key_metric" not in job_call.keywords:
            return (
                "accuracy",
                "default",
                _unresolved(
                    "objective.metric",
                    "job call passes **kwargs; key_metric may be supplied dynamically; "
                    "write key_metric explicitly in the job call",
                ),
            )
        if job_call and "key_metric" in job_call.keywords:
            resolved = _resolve_value(job_call.keywords["key_metric"], job_call.assignments, parser_args, source_text)
            if isinstance(resolved.value, str) and not resolved.unresolved:
                return resolved.value, resolved.source, None
            return "accuracy", "default", _unresolved("objective.metric", resolved.source)
        if job_call:
            return "accuracy", "core_default", None
        return (
            "accuracy",
            "default",
            _unresolved("objective.metric", "metric defaulted to accuracy; validation metric source is unknown"),
        )

    def _resolve_metric_mode(
        self,
        job_metric: str,
        job_call: Optional[CallInfo],
        parser_args: Dict[str, ArgSpec],
        source_text: str,
    ) -> Tuple[str, str, Optional[Dict[str, str]]]:
        if not job_call:
            return "max", "unresolved", _unresolved("objective.mode", "job metric direction is unknown")

        if job_call.has_positional_args:
            return (
                "max",
                "unresolved",
                _unresolved(
                    "objective.mode",
                    _positional_call_reason(
                        job_call,
                        "direction-relevant values may be supplied positionally; use keyword-only constructor arguments",
                    ),
                ),
            )

        if job_call.has_keyword_splat:
            return (
                "max",
                "unresolved",
                _unresolved(
                    "objective.mode",
                    "job call passes **kwargs; direction-relevant values may be supplied dynamically; "
                    "remove **kwargs and write the applicable direction keyword explicitly in the job call",
                ),
            )

        model_selector = job_call.keywords.get("model_selector")
        if model_selector is not None:
            resolved_selector = _resolve_value(model_selector, job_call.assignments, parser_args, source_text)
            if resolved_selector.unresolved or resolved_selector.value:
                return (
                    "max",
                    "unresolved",
                    _unresolved(
                        "objective.mode",
                        "custom model_selector controls checkpoint selection, so key_metric_mode is not effective "
                        "and selector direction cannot be imported deterministically",
                    ),
                )

        explicit_mode = job_call.keywords.get("key_metric_mode")
        if explicit_mode is not None:
            resolved = _resolve_value(explicit_mode, job_call.assignments, parser_args, source_text)
            if resolved.unresolved:
                return "max", "unresolved", _unresolved("objective.mode", resolved.source)
            if resolved.value is not None:
                if not isinstance(resolved.value, str) or resolved.value not in SUPPORTED_OBJECTIVE_MODES:
                    return (
                        "max",
                        "unresolved",
                        _unresolved("objective.mode", f"invalid key_metric_mode: {resolved.value!r}"),
                    )
                mode_source = "job:key_metric_mode" if resolved.source == "literal" else resolved.source
                stop_mode, stop_issue = _resolve_stop_condition_mode(job_metric, job_call, parser_args, source_text)
                if stop_issue:
                    return resolved.value, mode_source, stop_issue
                if stop_mode and stop_mode != resolved.value:
                    return (
                        resolved.value,
                        mode_source,
                        _unresolved(
                            "objective.mode",
                            "key_metric_mode conflicts with same-metric stop_cond",
                        ),
                    )
                return resolved.value, mode_source, None

        stop_mode, stop_issue = _resolve_stop_condition_mode(job_metric, job_call, parser_args, source_text)
        if stop_issue:
            return "max", "unresolved", stop_issue
        if stop_mode:
            return stop_mode, "job:stop_cond", None
        return "max", "core_default", None

    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 "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 job_call.has_positional_args:
                unresolved.append(
                    _unresolved(
                        "budget.fixed_training_budget",
                        _positional_call_reason(
                            job_call,
                            "fixed training budget values may be supplied positionally; "
                            "use keyword-only constructor arguments",
                        ),
                    )
                )
            if job_call.has_keyword_splat:
                unresolved.append(
                    _unresolved(
                        "budget.fixed_training_budget",
                        "job call passes **kwargs; fixed training budget values may be supplied dynamically; "
                        "remove **kwargs and write the applicable budget keywords explicitly in the job call",
                    )
                )

        if env_call and env_call.name == "SimEnv":
            resolved = _resolve_sim_env_num_clients(env_call, 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.has_positional_args:
                unresolved.append(
                    _unresolved(
                        "budget.fixed_training_budget.num_clients",
                        _positional_call_reason(
                            env_call,
                            "the client count may be supplied positionally; use keyword-only SimEnv arguments",
                        ),
                    )
                )

        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": {}, "simulator_env_passthrough": []}
        if env_call and env_call.name == "SimEnv":
            sim_profile: Dict[str, Any] = {}
            resolved = _resolve_sim_env_num_clients(env_call, 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
                reason = f"default is dynamic expression: {arg_spec.default}"
                if arg_spec.default_source == "required_positional":
                    reason = "required positional argument has no deterministic default"
                unresolved.append(
                    _unresolved(
                        f"search_space.suggested.{arg_name}.default",
                        reason,
                    )
                )
            if not arg_spec.flags:
                item["confidence"] = "low"
                item["mutable_via_run_args"] = False
                item["unresolved"] = True
                unresolved.append(
                    _unresolved(
                        f"search_space.suggested.{arg_name}.interface",
                        "positional argparse fields require source edits; candidate run_args support long options only",
                    )
                )
            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 and key != "model":
                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,
    target_env: Optional[str] = None,
    max_candidates: Optional[int] = None,
    job_args: Optional[Sequence[str]] = None,
) -> Dict[str, Any]:
    """Convenience wrapper for deterministic job import."""

    importer = DeterministicJobImporter(workspace_root=workspace_root)
    return importer.import_job(
        job_path,
        metric=metric,
        target_env=target_env,
        max_candidates=max_candidates,
        job_args=job_args,
    )


def inspect_job_cli_flags(job_path: str, job_args: Optional[Sequence[str]] = None) -> List[str]:
    """Return long argparse flags reachable from a job's deterministic entry path."""

    path = Path(job_path).resolve()
    try:
        source_text = path.read_text(encoding="utf-8")
        tree = ast.parse(source_text, filename=str(path))
        index = _ImportIndex.from_tree(tree, source_text)
    except (OSError, UnicodeError, SyntaxError, RecursionError) as e:
        raise JobImportError(f"failed to parse {path}: {e}") from e
    parser_args, _ = _reachable_parser_args(tree, index, job_args or [])
    return sorted({flag for spec in parser_args.values() for flag in spec.flags if flag.startswith("--")})


def inspect_job_cli_flag_aliases(job_path: str, job_args: Optional[Sequence[str]] = None) -> List[List[str]]:
    """Return each reachable argparse option's flag spellings (short and long) as one sorted group."""

    path = Path(job_path).resolve()
    try:
        source_text = path.read_text(encoding="utf-8")
        tree = ast.parse(source_text, filename=str(path))
        index = _ImportIndex.from_tree(tree, source_text)
    except (OSError, UnicodeError, SyntaxError, RecursionError) as e:
        raise JobImportError(f"failed to parse {path}: {e}") from e
    parser_args, _ = _reachable_parser_args(tree, index, job_args or [])
    return sorted(sorted(spec.flags) for spec in parser_args.values() if spec.flags)


class _ImportIndex(ast.NodeVisitor):
    def __init__(self, source_text: str):
        self.source_text = source_text
        self.imports: Dict[str, str] = {}
        self.parser_arg_definitions: Dict[str, List[ArgSpec]] = {}
        self.module_assignments: Dict[str, List[AssignmentInfo]] = {}
        self._local_assignments_stack: List[Dict[str, List[AssignmentInfo]]] = []
        self._function_stack: List[str] = []
        self._branch_conditions: List[Tuple[ast.AST, bool]] = []
        self._argparse_parser_names: Set[Tuple[Optional[str], str]] = set()
        self._argparse_subparser_names: Set[Tuple[Optional[str], str]] = set()
        self.job_calls: List[CallInfo] = []
        self.unsupported_job_calls: List[CallInfo] = []
        self.script_runner_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,
        reachable_functions: Optional[Set[str]] = None,
        parser_args: Optional[Dict[str, ArgSpec]] = None,
    ) -> Optional[CallInfo]:
        return _first_reachable_call(self.job_calls, reachable_functions, parser_args)

    def first_env_call(
        self,
        reachable_functions: Optional[Set[str]] = None,
        parser_args: Optional[Dict[str, ArgSpec]] = None,
    ) -> Optional[CallInfo]:
        return _first_reachable_call(self.env_calls, reachable_functions, parser_args)

    def first_unsupported_job_call(self) -> Optional[CallInfo]:
        return self.unsupported_job_calls[0] if self.unsupported_job_calls else None

    def parser_args(self, reachable_functions: Optional[Set[str]] = None) -> Dict[str, ArgSpec]:
        definitions = {}
        for name, specs in self.parser_arg_definitions.items():
            selected = [
                spec
                for spec in specs
                if reachable_functions is None
                or spec.function_name is None
                or spec.function_name in reachable_functions
            ]
            if selected:
                definitions[name] = selected
        return _collapse_arg_specs(definitions)

    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._visit_function(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self._visit_function(node)

    def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> 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._record_assignment(target.id, node.value)
                self._track_argparse_assignment(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._record_assignment(node.target.id, node.value)
            self._track_argparse_assignment(node.target.id, node.value)
        self.generic_visit(node)

    def visit_AugAssign(self, node: ast.AugAssign) -> None:
        if isinstance(node.target, ast.Name):
            # The result depends on runtime state. Preserve the expression so
            # downstream resolution marks the value as unresolved instead of
            # reusing an earlier static assignment.
            self._record_assignment(node.target.id, node)
        self.generic_visit(node)

    def visit_If(self, node: ast.If) -> None:
        self.visit(node.test)
        self._visit_conditional_branch(node.body, node.test, True)
        self._visit_conditional_branch(node.orelse, node.test, False)

    def _visit_conditional_branch(self, statements: Sequence[ast.stmt], condition: ast.AST, expected: bool) -> None:
        self._branch_conditions.append((condition, expected))
        for statement in statements:
            self.visit(statement)
        self._branch_conditions.pop()

    def visit_Call(self, node: ast.Call) -> None:
        call_name = _call_name(node.func)
        if self._is_argparse_add_argument_call(call_name):
            arg_spec = _arg_spec_from_call(node, self._function_stack[-1] if self._function_stack else None)
            if arg_spec:
                self.parser_arg_definitions.setdefault(arg_spec.name, []).append(arg_spec)

        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=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,
                branch_conditions=tuple(self._branch_conditions),
                has_keyword_splat=any(keyword.arg is None for keyword in node.keywords),
                has_positional_args=bool(node.args),
                has_positional_splat=any(isinstance(arg, ast.Starred) for arg in node.args),
            )
            if is_environment:
                self.env_calls.append(call_info)
            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 _track_argparse_assignment(self, target: str, value: ast.AST) -> None:
        if not isinstance(value, ast.Call):
            return
        call_name = self._resolve_import_path(_call_name(value.func))
        scope = self._function_stack[-1] if self._function_stack else None
        if call_name in {"argparse.ArgumentParser", "argparse._ArgumentGroup"}:
            self._argparse_parser_names.add((scope, target))
            return
        owner, _, method = call_name.rpartition(".")
        if method == "add_subparsers" and self._is_known_argparse_name(owner, self._argparse_parser_names):
            self._argparse_subparser_names.add((scope, target))
        elif method == "add_parser" and self._is_known_argparse_name(owner, self._argparse_subparser_names):
            self._argparse_parser_names.add((scope, target))

    def _is_argparse_add_argument_call(self, call_name: str) -> bool:
        owner, _, method = call_name.rpartition(".")
        return method == "add_argument" and self._is_known_argparse_name(owner, self._argparse_parser_names)

    def _is_known_argparse_name(self, name: str, names: Set[Tuple[Optional[str], str]]) -> bool:
        scope = self._function_stack[-1] if self._function_stack else None
        return (scope, name) in names or (None, name) in names

    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, List[AssignmentInfo]]:
        if self._local_assignments_stack:
            return self._local_assignments_stack[-1]
        return self.module_assignments

    def _record_assignment(self, name: str, value: ast.AST) -> None:
        assignment = AssignmentInfo(value=value, branch_conditions=tuple(self._branch_conditions))
        self._current_assignments().setdefault(name, []).append(assignment)

    def _resolution_assignments(self) -> Dict[str, Tuple[AssignmentInfo, ...]]:
        assignments = {name: tuple(history) for name, history in self.module_assignments.items()}
        if self._local_assignments_stack:
            assignments.update({name: tuple(history) for name, history in self._local_assignments_stack[-1].items()})
        return assignments


def _first_reachable_call(
    calls: Sequence[CallInfo],
    reachable_functions: Optional[Set[str]],
    parser_args: Optional[Dict[str, ArgSpec]] = None,
) -> Optional[CallInfo]:
    if not calls:
        return None
    arg_values = {name: spec.default for name, spec in (parser_args or {}).items() if not spec.default_unresolved}
    reachable_calls = [
        call
        for call in calls
        if (reachable_functions is None or call.function_name is None or call.function_name in reachable_functions)
        and _matches_static_branch_conditions(call, arg_values)
    ]
    return reachable_calls[0] if reachable_calls else None


def _matches_static_branch_conditions(call: CallInfo, arg_values: Dict[str, Any]) -> bool:
    return _static_branch_conditions_match(call.branch_conditions, arg_values) is not False


def _static_branch_conditions_match(
    branch_conditions: Sequence[Tuple[ast.AST, bool]], arg_values: Dict[str, Any]
) -> Optional[bool]:
    unknown = False
    for condition, expected in branch_conditions:
        value = _static_condition_value(condition, arg_values)
        if value is None:
            unknown = True
        elif value != expected:
            return False
    return None if unknown else True


def _arg_spec_signature(spec: ArgSpec) -> Tuple[Any, ...]:
    return (
        spec.default,
        spec.default_source,
        spec.default_unresolved,
        spec.value_type,
        tuple(spec.choices) if spec.choices is not None else None,
        spec.action,
    )


def _collapse_arg_specs(definitions: Dict[str, List[ArgSpec]]) -> Dict[str, ArgSpec]:
    collapsed = {}
    for name, specs in definitions.items():
        flags = tuple(dict.fromkeys(flag for spec in specs for flag in spec.flags))
        first = specs[0]
        if all(_arg_spec_signature(spec) == _arg_spec_signature(first) for spec in specs[1:]):
            collapsed[name] = ArgSpec(
                name=name,
                flags=flags,
                default=first.default,
                default_source=first.default_source,
                default_unresolved=first.default_unresolved,
                value_type=first.value_type,
                choices=first.choices,
                action=first.action,
                function_name=first.function_name,
            )
            continue
        same_value_type = all(spec.value_type == first.value_type for spec in specs[1:])
        same_action = all(spec.action == first.action for spec in specs[1:])
        same_choices = all(spec.choices == first.choices for spec in specs[1:])
        collapsed[name] = ArgSpec(
            name=name,
            flags=flags,
            default=None,
            default_source="conflicting argparse definitions",
            default_unresolved=True,
            value_type=first.value_type if same_value_type else None,
            choices=first.choices if same_choices else None,
            action=first.action if same_action else None,
        )
    return collapsed


def _reachable_parser_args(
    tree: ast.AST, index: _ImportIndex, job_args: Sequence[str]
) -> Tuple[Dict[str, ArgSpec], Set[str]]:
    initial = _parser_args_with_cli_overrides(index.parser_args(), job_args)
    reachable = _reachable_function_names(tree, initial)
    parser_args = _parser_args_with_cli_overrides(index.parser_args(reachable), job_args)
    reachable = _reachable_function_names(tree, parser_args)
    return _parser_args_with_cli_overrides(index.parser_args(reachable), job_args), reachable


def _parser_args_with_cli_overrides(parser_args: Dict[str, ArgSpec], job_args: Sequence[str]) -> Dict[str, ArgSpec]:
    resolved = dict(parser_args)
    flags = {flag: spec for spec in parser_args.values() for flag in spec.flags}
    index = 0
    while index < len(job_args):
        token = job_args[index]
        flag, separator, inline_value = token.partition("=")
        spec = flags.get(flag)
        if spec is None:
            index += 1
            continue

        if spec.action in {"store_true", "store_false"}:
            value = spec.action == "store_true"
            consumed = 1
        elif separator:
            value = _coerce_cli_value(inline_value, spec)
            consumed = 1
        elif index + 1 < len(job_args):
            value = _coerce_cli_value(job_args[index + 1], spec)
            consumed = 2
        else:
            index += 1
            continue

        resolved[spec.name] = ArgSpec(
            name=spec.name,
            flags=spec.flags,
            default=value,
            default_source="job_cli_arg",
            value_type=spec.value_type,
            choices=spec.choices,
            action=spec.action,
        )
        index += consumed
    return resolved


def _coerce_cli_value(value: str, spec: ArgSpec) -> Any:
    value_type = (spec.value_type or "").split(".")[-1]
    try:
        if value_type == "int":
            return int(value)
        if value_type == "float":
            return float(value)
    except ValueError:
        return value
    return value


def _reachable_function_names(tree: ast.AST, parser_args: Dict[str, ArgSpec]) -> Set[str]:
    functions = {
        node.name: node
        for node in getattr(tree, "body", [])
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
    }
    if "main" not in functions:
        return set(functions)

    arg_values = {name: spec.default for name, spec in parser_args.items() if not spec.default_unresolved}
    reachable = set()
    pending = ["main"]
    while pending:
        name = pending.pop()
        if name in reachable or name not in functions:
            continue
        reachable.add(name)
        called = _called_functions(functions[name].body, arg_values)
        pending.extend(sorted((called & functions.keys()) - reachable, reverse=True))
    return reachable


def _called_functions(statements: Sequence[ast.stmt], arg_values: Dict[str, Any]) -> Set[str]:
    calls: Set[str] = set()
    for statement in statements:
        if isinstance(statement, ast.If):
            condition = _static_condition_value(statement.test, arg_values)
            if condition is True:
                calls.update(_called_functions(statement.body, arg_values))
            elif condition is False:
                calls.update(_called_functions(statement.orelse, arg_values))
            else:
                calls.update(_called_functions(statement.body, arg_values))
                calls.update(_called_functions(statement.orelse, arg_values))
            continue
        collector = _DirectCallCollector()
        collector.visit(statement)
        calls.update(collector.names)
    return calls


class _DirectCallCollector(ast.NodeVisitor):
    def __init__(self):
        self.names: Set[str] = set()

    def visit_Call(self, node: ast.Call) -> None:
        if isinstance(node.func, ast.Name):
            self.names.add(node.func.id)
        self.generic_visit(node)

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        return

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        return

    def visit_Lambda(self, node: ast.Lambda) -> None:
        return


def _static_condition_value(node: ast.AST, arg_values: Dict[str, Any]) -> Optional[bool]:
    if isinstance(node, ast.Compare) and len(node.ops) == 1 and len(node.comparators) == 1:
        left_known, left = _static_condition_operand(node.left, arg_values)
        right_known, right = _static_condition_operand(node.comparators[0], arg_values)
        if not left_known or not right_known:
            return None
        if isinstance(node.ops[0], ast.Eq):
            return left == right
        if isinstance(node.ops[0], ast.NotEq):
            return left != right
    if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
        value = _static_condition_value(node.operand, arg_values)
        return None if value is None else not value
    known, value = _static_condition_operand(node, arg_values)
    if known:
        return bool(value)
    return None


def _static_condition_operand(node: ast.AST, arg_values: Dict[str, Any]) -> Tuple[bool, Any]:
    is_literal, value = _literal_value(node)
    if is_literal:
        return True, value
    if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == "args":
        if node.attr in arg_values:
            return True, arg_values[node.attr]
    return False, None


def _collect_argparse_args_from_file(path: Optional[Path]) -> Dict[str, ArgSpec]:
    if not path or not path.exists():
        return {}
    try:
        source_text = path.read_text(encoding="utf-8")
        tree = ast.parse(source_text, filename=str(path))
        index = _ImportIndex.from_tree(tree, source_text)
    except (OSError, UnicodeError, SyntaxError, RecursionError):
        return {}
    parser_args, _ = _reachable_parser_args(tree, index, ())
    return parser_args


def _arg_spec_from_call(node: ast.Call, function_name: Optional[str]) -> Optional[ArgSpec]:
    flags = []
    positional_names = []
    for arg in node.args:
        is_literal, value = _literal_value(arg)
        if not is_literal or not isinstance(value, str):
            continue
        if value.startswith("-"):
            flags.append(value)
        else:
            positional_names.append(value)
    if not flags and len(positional_names) != 1:
        return None

    keywords = {keyword.arg: keyword.value for keyword in node.keywords if keyword.arg}
    if "dest" in keywords:
        dest_is_literal, name = _literal_value(keywords["dest"])
        if dest_is_literal and not isinstance(name, str):
            return None
        if not dest_is_literal:
            name = _name_from_flags(flags) if flags else positional_names[0]
    else:
        name = _name_from_flags(flags) if flags else positional_names[0]
    if not name:
        return None

    action = None
    if "action" in keywords:
        action_is_literal, action_value = _literal_value(keywords["action"])
        if action_is_literal:
            if not isinstance(action_value, str):
                return None
            action = action_value
    default, default_source, default_unresolved = _arg_default_from_keywords(keywords)
    if not flags and "default" not in keywords:
        default_source = "required_positional"
        default_unresolved = True
    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,
        function_name=function_name,
    )


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, Tuple[AssignmentInfo, ...]],
    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:
            assignment, issue = _select_assignment(node.id, assignments[node.id], parser_args)
            if issue:
                return ResolvedValue(None, issue, "low", True)
            return _resolve_value(assignment, assignments, parser_args, source_text, seen | {node.id})
        return ResolvedValue(node.id, f"name:{node.id}", "low", True)

    if isinstance(node, ast.Call):
        call_name = _call_name(node.func)
        if call_name in {"Path", "pathlib.Path", "os.path.join"}:
            path_value = _resolve_path_call(node, call_name, assignments, parser_args, source_text)
            if path_value is not None:
                return path_value
        return ResolvedValue(_source_segment(source_text, node) or call_name, f"call:{call_name}", "low", True)

    if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
        left = _resolve_value(node.left, assignments, parser_args, source_text)
        right = _resolve_value(node.right, assignments, parser_args, source_text)
        if all(isinstance(value.value, str) and not value.unresolved for value in (left, right)):
            confidence = "low" if "low" in {left.confidence, right.confidence} else "high"
            return ResolvedValue(
                str(Path(left.value) / right.value),
                f"path:{left.source}+{right.source}",
                confidence,
            )

    return ResolvedValue(_source_segment(source_text, node) or type(node).__name__, "expression", "low", True)


def _select_assignment(
    name: str,
    history: Sequence[AssignmentInfo],
    parser_args: Dict[str, ArgSpec],
) -> Tuple[Optional[ast.AST], Optional[str]]:
    arg_values = {key: spec.default for key, spec in parser_args.items() if not spec.default_unresolved}
    for assignment in reversed(history):
        branch_match = _static_branch_conditions_match(assignment.branch_conditions, arg_values)
        if branch_match is False:
            continue
        if branch_match is None:
            return None, f"conditional assignment for {name} cannot be resolved from job arguments"
        return assignment.value, None
    return None, f"no reachable assignment for {name}"


def _resolve_sim_env_num_clients(
    env_call: CallInfo,
    parser_args: Dict[str, ArgSpec],
    source_text: str,
) -> ResolvedValue:
    """Resolve the effective client count using ``SimEnv`` runtime semantics."""

    num_clients_node = env_call.keywords.get("num_clients")
    if num_clients_node is not None:
        num_clients = _resolve_value(num_clients_node, env_call.assignments, parser_args, source_text)
        if num_clients.unresolved:
            return ResolvedValue(None, f"num_clients is dynamic: {num_clients.source}", "low", True)
        if isinstance(num_clients.value, bool) or not isinstance(num_clients.value, int):
            return ResolvedValue(None, "num_clients must resolve to an integer", "low", True)
        if num_clients.value > 0:
            return num_clients

    clients_node = env_call.keywords.get("clients")
    if clients_node is not None:
        clients = _resolve_value(clients_node, env_call.assignments, parser_args, source_text)
        if clients.unresolved:
            return ResolvedValue(None, f"clients is dynamic: {clients.source}", "low", True)
        if not isinstance(clients.value, list) or not clients.value:
            return ResolvedValue(None, "clients must resolve to a non-empty static list", "low", True)
        return ResolvedValue(len(clients.value), f"len(clients):{clients.source}")

    if env_call.has_keyword_splat:
        return ResolvedValue(
            None,
            "SimEnv call passes **kwargs; num_clients or clients may be supplied dynamically; "
            "write a positive num_clients or a non-empty static clients list explicitly in the SimEnv call",
            "low",
            True,
        )
    return ResolvedValue(
        None,
        "SimEnv client count is unresolved; write a positive num_clients or a non-empty static clients list",
        "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 _resolve_stop_condition_mode(
    job_metric: str,
    job_call: CallInfo,
    parser_args: Dict[str, ArgSpec],
    source_text: str,
) -> Tuple[Optional[str], Optional[Dict[str, str]]]:
    stop_condition = job_call.keywords.get("stop_cond")
    if stop_condition is None:
        return None, None
    resolved = _resolve_value(stop_condition, job_call.assignments, parser_args, source_text)
    if resolved.unresolved:
        return None, _unresolved("objective.mode", f"stop_cond is dynamic: {resolved.source}")
    if resolved.value is None:
        return None, None
    if not isinstance(resolved.value, str):
        return None, _unresolved("objective.mode", "stop_cond is not a string")
    tokens = resolved.value.split(" ")
    if len(tokens) != 3 or tokens[1] not in {"<", "<=", "=", ">", ">="}:
        return None, _unresolved("objective.mode", f"invalid stop_cond: {resolved.value!r}")
    try:
        float(tokens[2])
    except (TypeError, ValueError):
        return None, _unresolved("objective.mode", f"invalid stop_cond: {resolved.value!r}")
    stop_metric, operator = tokens[:2]
    if stop_metric != job_metric:
        return None, None
    if operator in {"<", "<="}:
        return "min", None
    if operator in {">", ">="}:
        return "max", None
    return None, None


def _resolve_core_heuristic():
    """Load and cache the core heuristic, retrying after transient import failures."""

    global _core_looks_lower_is_better, _last_core_heuristic_warning

    if _core_looks_lower_is_better is _UNRESOLVED:
        try:
            from nvflare.app_common.widgets.intime_model_selector import _looks_lower_is_better
        except Exception as e:
            warning = (type(e).__name__, str(e))
            if warning != _last_core_heuristic_warning:
                logger.warning(
                    "Unable to load the NVFlare core metric heuristic (%s: %s); using the local fallback and "
                    "retrying on the next check",
                    *warning,
                )
                _last_core_heuristic_warning = warning
            return None
        else:
            _core_looks_lower_is_better = _looks_lower_is_better
    return _core_looks_lower_is_better


def likely_lower_is_better_metric(metric: str) -> bool:
    """Return whether a metric name is an obvious lower-is-better objective."""

    core_heuristic = _resolve_core_heuristic()
    if core_heuristic is not None:
        return core_heuristic(metric)
    return _fallback_looks_lower_is_better(metric)


def _fallback_looks_lower_is_better(metric: str) -> bool:
    name = metric.lower()
    tokens = re.split(r"[^a-z0-9]+", name)
    if ALREADY_NEGATED_METRIC_TOKEN in tokens:
        return False
    if any(hint in name for hint in LOWER_IS_BETTER_METRIC_SUBSTRINGS):
        return True
    return any(token in LOWER_IS_BETTER_METRIC_TOKENS for token in tokens)


def _resolve_path_call(
    node: ast.Call,
    call_name: str,
    assignments: Dict[str, Tuple[AssignmentInfo, ...]],
    parser_args: Dict[str, ArgSpec],
    source_text: str,
) -> Optional[ResolvedValue]:
    resolved_parts = []
    for arg in node.args:
        resolved = _resolve_value(arg, assignments, parser_args, source_text)
        if resolved.unresolved or not isinstance(resolved.value, str):
            return None
        resolved_parts.append(resolved)
    if not resolved_parts:
        return None

    values = [part.value for part in resolved_parts]
    value = os.path.join(*values) if call_name == "os.path.join" else str(Path(*values))
    confidence = "low" if any(part.confidence == "low" for part in resolved_parts) else "high"
    sources = "+".join(part.source for part in resolved_parts)
    return ResolvedValue(value, f"path:{sources}", confidence)


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_supported_job_call(name: str, resolved_name: str) -> bool:
    if not resolved_name.startswith("nvflare."):
        return False
    if name.endswith("Recipe"):
        return True
    return name.endswith("Job") and name != "Job"


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 _support_status(job_call: Optional[CallInfo]) -> Tuple[str, Optional[str]]:
    if not job_call:
        return "partial", "no supported Recipe or NVFlare FedJob constructor was found"
    if job_call.name in NON_OPTIMIZATION_RECIPE_NAMES:
        return (
            "partial",
            f"{job_call.name} is evaluation/statistics-only and has no training loop for Auto-FL optimization",
        )
    if job_call.name in UNSUPPORTED_NESTED_RECIPE_NAMES:
        return (
            "partial",
            f"{job_call.name} wraps a nested application whose training source cannot be imported deterministically",
        )
    return "supported", None


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],
    objective: Dict[str, Any],
    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": objective["metric"]})
    extracted.append({"field": "objective.optimization_metric", "value": objective["optimization_metric"]})
    extracted.append({"field": "objective.mode", "value": objective["mode"]})
    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 _objective_contract(
    metric_name: str,
    source: str,
    mode: str,
    mode_source: str,
    job_metric: str,
    job_metric_source: str,
) -> Dict[str, Any]:
    return {
        "metric": metric_name,
        "requested_metric": metric_name,
        "optimization_metric": metric_name,
        "metric_extraction_order": [metric_name],
        "mode": mode,
        "mode_contract_source": mode_source,
        "job_key_metric": job_metric,
        "job_key_metric_source": job_metric_source,
        "job_key_metric_mode": mode,
        "job_key_metric_mode_source": mode_source,
        "metric_contract_source": source,
        "metric_invariants": list(METRIC_INVARIANTS),
        "metric_change_policy": METRIC_CHANGE_POLICY,
    }


def _has_main_entrypoint(tree: ast.AST) -> bool:
    return any(
        isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) 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 _is_resolved_path_string(value: ResolvedValue) -> bool:
    return not value.unresolved and isinstance(value.value, str)


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 _positional_call_reason(call_info: CallInfo, detail: str) -> str:
    argument_kind = "*args" if call_info.has_positional_splat else "positional arguments"
    return f"{call_info.name} call passes {argument_kind}; {detail}"

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Python

The author says: Requires NVFLARE 2.9.0+, Python, and permission to run NVFLARE jobs in the selected environment.

Security

What our scanner noticed· Medium risk

  • Large base64-encoded blobs
  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
NVIDIA FLARE Team
Publisher
NVIDIA (Official)
Source
NVIDIA/skills · skills/nvflare-autofl · commit fce3cca
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE-APACHE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by NVIDIA FLARE Team or NVIDIA.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download nvflare-autofl.zip