Add per-site client tracking and harden recipe client placement#4862
Conversation
- Recipe.add_client_component / Recipe.add_server_component: bounded placement primitive for components (tracking receivers, streamers) so users never need recipe.job.to(...) - add_experiment_tracking gains clients= for client-side targeting; per-site tracking configs are one call per site - guards with guiding errors: str/dict/Filter/Controller/Executor/ script runners are routed to their dedicated APIs - fail loudly when targeting specific clients on the default all-clients topology (previously such placements were silently dropped at export; also fixes the same latent drop in add_client_config/add_client_file/client filters with clients=[...]) - reject non-client names (server, @ALL) in clients lists - contract page updated: component helpers section, tracking signature, revised placement escape hatch Addresses Recipe API requirements 6 and 14; unblocks migrating tensor-stream and job_api tracking examples off recipe.job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR extends the NVFlare Recipe API with a bounded, recipe-level component placement surface and enhances experiment tracking to support per-site client receiver targeting without requiring direct recipe.job manipulation. It also updates documentation and adds unit tests to validate placement behavior, topology constraints, and export serialization.
Changes:
- Add
Recipe.add_client_component(...)andRecipe.add_server_component(...)as recipe-level primitives for placing plain components into generated client/server app configs. - Extend
add_experiment_tracking(...)with aclientsparameter and route client receiver placement through the new Recipe helper to preserve existing per-site client app topologies. - Update Recipe API documentation and add unit tests covering targeting rules, error guards, and export config serialization.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
tests/unit_test/recipe/utils_test.py |
Adds coverage for add_experiment_tracking(..., clients=...) per-site behavior and export survival. |
tests/unit_test/recipe/spec_test.py |
Adds coverage for new Recipe component placement helpers and their guardrails. |
nvflare/recipe/utils.py |
Adds clients targeting support to add_experiment_tracking and routes placement via Recipe helper. |
nvflare/recipe/spec.py |
Implements add_client_component / add_server_component and strengthens client-placement logic. |
docs/user_guide/data_scientist_guide/recipe_api.rst |
Documents new component helpers and updated tracking signature/semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Greptile SummaryThis PR adds a
Confidence Score: 5/5Safe to merge. The placement-layer hardening converts previously silent no-ops and silent drops into explicit errors, which is strictly an improvement over the status quo. All five new validation guards in No files require special attention. The Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["add_experiment_tracking(recipe, ..., clients=...)"] --> B{clients is not None?}
B -- No --> C["_add_to_client_apps(obj, clients=None)"]
B -- Yes --> D{client_side=True?}
D -- No --> E["raise ValueError\n(clients only for client_side)"]
D -- Yes --> F{isinstance list of str?}
F -- No --> G["raise TypeError"]
F -- Yes --> H{clients empty?}
H -- Yes --> I["raise ValueError\n(must not be empty)"]
H -- No --> J["_add_to_client_apps(obj, clients=clients)"]
C --> K{existing_client_sites?}
K -- Yes --> L["job.to(obj, site) for each site"]
K -- No --> M["job.to_clients(obj)"]
J --> N{ALL_SITES in deploy_map?}
N -- Yes --> O["raise ValueError\n(all-clients topology)"]
N -- No --> P{existing_client_sites non-empty?}
P -- Yes --> Q{unknown sites?}
Q -- Yes --> R["raise ValueError\n(list known sites)"]
Q -- No --> S["job.to(obj, client) for each client"]
P -- No --> S
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["add_experiment_tracking(recipe, ..., clients=...)"] --> B{clients is not None?}
B -- No --> C["_add_to_client_apps(obj, clients=None)"]
B -- Yes --> D{client_side=True?}
D -- No --> E["raise ValueError\n(clients only for client_side)"]
D -- Yes --> F{isinstance list of str?}
F -- No --> G["raise TypeError"]
F -- Yes --> H{clients empty?}
H -- Yes --> I["raise ValueError\n(must not be empty)"]
H -- No --> J["_add_to_client_apps(obj, clients=clients)"]
C --> K{existing_client_sites?}
K -- Yes --> L["job.to(obj, site) for each site"]
K -- No --> M["job.to_clients(obj)"]
J --> N{ALL_SITES in deploy_map?}
N -- Yes --> O["raise ValueError\n(all-clients topology)"]
N -- No --> P{existing_client_sites non-empty?}
P -- Yes --> Q{unknown sites?}
Q -- Yes --> R["raise ValueError\n(list known sites)"]
Q -- No --> S["job.to(obj, client) for each client"]
P -- No --> S
Reviews (7): Last reviewed commit: "Merge branch 'main' into codex/recipe-co..." | Re-trigger Greptile |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4862 +/- ##
==========================================
+ Coverage 60.30% 60.32% +0.02%
==========================================
Files 973 973
Lines 92233 92253 +20
==========================================
+ Hits 55619 55655 +36
+ Misses 36614 36598 -16
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- reject non-list clients (a bare string would iterate per character) and empty clients lists (previously a silent no-op) in _add_to_client_apps and add_experiment_tracking - consolidate the Controller guard into _validate_placed_component, checked before Executor so a hybrid subclass gets the accurate message; removes the duplicated per-method checks - document that add_client_component returns None (per-app id suffixing means no single id exists) and reflect the add_to_fed_job return caveat on the contract page - collapse implicit string-literal concatenation in guard messages into single literals Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
set_per_site_config currently only records helper config for configured_sites(); the base _apply_per_site_config hook is a no-op and no concrete recipe overrides it yet, so it does not rebuild an existing all-clients app into per-site apps. Pointing the targeted-placement error at it was misleading: following that advice would not resolve the error. Error message, docstrings, and the contract page now recommend the per_site_config constructor argument, and the contract page states explicitly that helper-driven per-site topology is follow-up work per recipe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A requirement trace showed no current requirement needs public add_client_component/add_server_component: Req 14 (per-site tracking receivers) is fully covered by add_experiment_tracking(clients=...); Req 6 (deploy map) is per-site app topology, not component placement; and an inventory of all recipe.job.to* usages in the tree maps every real placement to an existing named helper (add_cross_site_evaluation), a dedicated API (add_server_config), or a design-doc-classified lower-level Job API example (tensor-stream). Per the 2.9 design decision, arbitrary component placement stays out of the public Recipe contract; this also resolves the reviewer's add_to_fed_job hook contract question by removing the surface it applied to. Kept: the _add_to_client_apps hardening (silent-drop topology guard, non-list/empty/bad-name rejection) — it protects the existing public clients=-taking helpers and the tracking targeting path — with test coverage ported to those public surfaces.
When per-site client apps exist, targeting a site with no app silently created a bare, executor-less app for that site in the exported job — the same class of quiet misconfiguration the earlier topology guard turns into loud errors. _add_to_client_apps now validates that every name in clients matches an existing per-site client app when per-site apps exist, listing the known sites in the error. Creating apps from scratch on an empty deploy map remains permitted. Per-site tracking tests now pre-seed per-site apps, mirroring real recipes where apps exist (e.g. via per_site_config) before tracking is added.
Replaces public Recipe.job usage with purpose-built Recipe APIs. ### Description Builds on #4862 to remove direct `recipe.job` usage from public examples, documentation, and research jobs. - Adds `recipe.enable_tensor_streaming(...)` for configuring matching server and client tensor streamers. - Adds `add_final_global_evaluation(...)` for evaluating a PyTorch recipe’s persisted global model without requesting client model submissions. - Migrates log streaming, file packaging, server configuration, export, experiment tracking, and final evaluation examples to supported Recipe APIs. - Expands the client-side MLflow documentation to cover local stores, remote servers, simulation behavior, and per-site configuration. - Updates the new Kubernetes Recipe example to use `add_server_file()` and targeted `add_client_file()`. After these changes, there are no remaining `recipe.job` references under `examples/`, `docs/`, or `research/`. This PR intentionally does not rename the internal `Recipe.job` attribute to `_job`. That mechanical/internal migration will be handled in a separate PR to keep this change focused and easier to review. ### Types of changes <!--- Put an `x` in all the boxes that apply, and remove the not applicable items --> - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [ ] Breaking change (fix or new feature that would cause existing functionality to change). - [ ] New tests added to cover the changes. - [ ] Quick tests passed locally by running `./runtest.sh`. - [ ] In-line docstrings updated. - [ ] Documentation updated. --------- Co-authored-by: Holger Roth <6304754+holgerroth@users.noreply.github.com>
Summary
clientsparameter toadd_experiment_tracking(...)for client-side receiver targeting; per-site tracking configs (e.g. per-sitetracking_uri) are one call per site — replaces therecipe.job.to(receiver, site_name)anti-pattern (Recipe API requirement 14)_add_to_client_apps, used byadd_client_config,add_client_file, client filter helpers, and the new tracking targeting): targeting specific clients while the job's client app applies to all clients now raises a clearValueErrorinstead of the placement being silently dropped at export; targeting a site with no existing client app (when per-site apps exist) raises with the list of configured sites instead of silently deploying a bare, executor-less app to that site; non-listclients(a bare string would iterate per character), empty lists, and non-client names (server,@ALL) are rejectedadd_experiment_trackingsignature and per-site usage; a general note thatclients=targeting requires per-site client apps (constructorper_site_config), and thatset_per_site_configdoes not yet rebuild an all-clients app (recipes implementing_apply_per_site_configis follow-up work)Why
Requirement 14: client-side tracking receivers with per-site configs required reaching into
recipe.job— the internal the contract page declares off-limits. The placement-layer hardening fixes real pre-existing bugs: targetedadd_client_config/add_client_file/filter calls on the default all-clients topology were silently dropped from the exported job.Tests
python3 -m pytest tests/unit_test/recipe/ -q(396 passed, 22 torch-gated skips)add_experiment_tracking,add_client_config,add_client_file)