refactor(fl)!: complete the NVFLARE Client-API migration — spleen tutorial ported, legacy Executor syntax retired, job types renamed, fed_opt migrated - #983
Merged
Conversation
…e NVFLARE Client API Port of flip-fl-base#116 into the mono-repo (#665), following the layout the xray_classification_client_api migration established: - New image_segmentation/3d_spleen_segmentation_client_api/ tutorial driving the standard_client_api job type: job.py wires FlipFedAvgRecipe (so the FLIP server components, analytics bridge and best-model selection all apply), and app_files/trainer.py replaces the legacy FLIP_TRAINER/FLIP_VALIDATOR executors with a single nvflare.client script (train + evaluate tasks). - models.py matches the evaluation_client_api spleen variant, so the state dict stays checkpoint-compatible with the legacy and evaluation tutorials. - Training metrics ride SummaryWriter tags (TRAIN_LOSS/VAL_LOSS/VAL_DICE @epoch, TEST_DICE) through FlipAnalyticsBridge; the trainer sends a DIFF update which FLIP's ScatterAndGather reconstructs into full WEIGHTS. - The trainer fails loudly when no image/label pair is found, naming the data-enrichment (label upload) step instead of dying with num_samples=0. - Removes the half-landed vanilla-FedAvgRecipe port (client.py/job.py/ model.py/transforms.py at the legacy tutorial root): it bypassed the FLIP platform integration (no FLIP components, vanilla WEIGHT_DIFF aggregation) and was superseded by this variant; the legacy Executor tutorial itself is unchanged and stays the standard-job-type reference. - Docs: component-fl-nodes tutorial list gains the new variant. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
8 tasks
… tutorials, templates and job types take the plain names Follows the user decision on #665's open consideration: parity between the Executor and Client-API paths is confirmed, so the legacy syntax goes. - Tutorials: the Executor variants of xray_classification, 3d_spleen_segmentation, 3d_spleen_segmentation_evaluation and latent_diffusion_model are removed and the *_client_api directories renamed onto the plain names (preserving the legacy dirs' unique assets: bundle export configs, the spleen utils/ download tooling + pyproject). The legacy Docker testing/ harness and the test-template target go with them — every tutorial now runs its job.py on SimEnv from the flip-utils venv. - Job types: fl-apps/nvflare/{standard,evaluation,diffusion_model} are now the recipe-generated Client-API templates; the aggregate manifest is regenerated (standard drops validator.py, evaluation gains models.py). BREAKING for uploads still declaring *_client_api job types; runtime aliases are kept where pre-rename models are read back (fl_service.py evaluation handling, flip.export.bundle). flip-ui demo mocks updated. - fed_opt migrates too: new FlipFedOptRecipe + FlipFedOptShareableGenerator mirror NVFlare's own Client-API FedOptRecipe layout (stock SAG + WEIGHT_DIFF aggregator + PTFedOptModelShareableGenerator) with FLIP's components; defaults follow stock NVFLARE FedOpt (SGD lr=1.0 momentum=0.6) — the retired template's Adam-at-0.5 was never live and destroys the model once aggregation works. Client contract identical to standard. - Bug fix (flip.nvflare.controllers.ScatterAndGather): the FedOpt guard in _diff_to_weights isinstance-checked the aggregator against the FedOpt shareable-GENERATOR class — never true — so DIFF→WEIGHTS reconstruction ran unconditionally and the legacy fed_opt job type never actually aggregated. The fixed guard keys on the aggregator's expected_data_kind, covering both its runtime shapes (plain DataKind and the normalised {name: kind} dict). - Docs (component-fl-nodes, flip-utils README/overview, template READMEs, CLAUDE.md/AGENTS.md) rewritten for the converged naming. Verified: flip-utils 732 / flip-api 1501 / fl-api-base 149 tests green; ruff clean; renamed spleen tutorial + FlipFedOptRecipe (zero rejections) green on the local simulator; renamed 'standard' job type green end-to-end on the dev stack (upload → bundle → train → RESULTS_UPLOADED, best+final models). Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…lution Codecov flagged the new component's handle_event as the only uncovered lines of the fed_opt migration: dict-config resolution to a live module, the missing-path panic, the string-id passthrough, and resolve-once semantics (the server optimizer state lives on the resolved model's parameters). Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…sh-init copy Review finding (critical): FlipFedOptShareableGenerator's dict-config source_model instantiates its own model object, independent of the persistor instance whose state_dict seeds the round-0 global. Stock PTFedOptModelShareableGenerator never syncs its copy from the global - it promotes self.model.state_dict() as the new global after every optimizer step - so from round 1 the global silently became fresh_random_init + lr*avg(diff): any SERVER_CHECKPOINT backbone and every frozen parameter was replaced with random weights. Invisible in the spleen sims (no checkpoint, so the run just restarts from the copy's init) - exactly the runs the tutorial-less fed_opt job type would never exercise. shareable_to_learnable now loads the live global into the copy in place before delegating to stock (load_state_dict copies into the existing tensors, so the optimizer built at START_RUN keeps its parameter references and momentum state; from round 2 the sync is a value-identical no-op). Also hardened to review findings on the same seam: - dict resolution is gated on START_RUN (where stock consumes source_model) and instantiate_class is wrapped in system_panic - the event dispatcher swallows handle_event exceptions, so a typo'd path or an ImportError inside the user's models.py previously wasted a full training round and then died as an opaque NoneType server error (stock wraps its own optimizer resolution for exactly this reason); - the recipe now mirrors stock FedOptRecipe's ensure_config_type_dict normalisation (a user dict without config_type='dict' made the server ComponentBuilder instantiate torch.optim.* at config-load time, sans params), deep-copies the module-level default (stock mutates optimizer_args['args'] in place at START_RUN, which would have poisoned the process-wide default), and rejects path-less optimizer_args at construction; - docstring corrections: the FedAvg hook applies the aggregated WEIGHT_DIFF (not WEIGHTS), the phantom _aggregator_data_kind cross-reference is gone, the CPU device default is FLIP's own choice (stock is cuda-if-available), and the claim that dict model refs import at recipe construction was empirically false (they resolve lazily - the fedopt recipe tests now run with no models stub). Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…s loudly Review finding (critical): with the DIFF->WEIGHTS bridge gone, a Client-API app sending params_type='FULL' - NVFLARE's default when params_type is omitted - had every contribution rejected by the WEIGHT_DIFF aggregator at log-only severity, each round aggregated an empty diff and applied it as a no-op, and the job ended COMPLETED with a model bit-identical to round 0 while the analytics bridge kept streaming healthy local loss curves. A regression in visibility terms against the deleted bridge, whose WEIGHTS-expecting aggregator ACCEPTED full sends. ScatterAndGather now: - panics on BEFORE_AGGREGATION when the finishing round accepted zero results (acceptance counts are final there and the empty aggregate has not yet been applied; there is no legitimate run where every client's update is rejected), relaying the abort to the hub first; - reports an aggregator-incompatible weights kind to the hub once per round with an actionable message naming the FLModel(params=diff, params_type='DIFF') contract, instead of stock's server-log-only rejection line; - relays EVERY failed client task to the hub, falling back to a pointed message naming the return code and the trust-side fl-client logs when no 'exception' header is present - the retired legacy executors were the only components that ever set that header, so the old EXECUTION_EXCEPTION-with-header-only relay had become dead code on the Client-API path (a Client-API script that dies pre-flare.init returns a bare TASK_ABORTED). Full traceback transport is FLIP#987. Also retires the stale _diff_to_weights references the review found in the controller test files (a vacuously-true inheritance parametrize entry, a test named for the deleted reconstruction, and the file header). Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…ifest validation
Review finding (critical, three reviewers independently): the kept
'evaluation_client_api' alias tuple was unreachable dead code - is_valid_job_type
rejects retired names against the renamed manifest 66 lines earlier, and the
alias template directory no longer exists - so every model created before the
rename hard-failed at training start with UnknownJobTypeError, directly
contradicting the documented 'aliases survive for pre-rename models' contract.
The unit test that appeared to cover the alias passed only because it mocked
is_valid_job_type to True and fabricated an alias base tree.
Both bundlers now resolve {standard,evaluation,diffusion_model}_client_api to
the plain names BEFORE validation (single _normalise_job_type helper), the
checkpoint-divert branch drops the now-redundant alias tuple, and
UnknownJobTypeError names the valid set plus the fix (edit config.json and
re-upload) - it surfaces at training start, long after upload/scan/approval all
succeeded.
Tests: the alias contract is now pinned UNMOCKED against the real in-repo
manifest (aliases resolve; plain names pass; the retired keys are genuinely
absent - the breaking manifest change itself; unknown names get the remediation
message), the eval-divert test proves the alias path bundles from the plain-name
template dir, and the fixture manifest mirrors the real post-rename contract
instead of teaching the retired file sets.
Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…ired Executor-era classes Review follow-through on the full-retirement goal: ModelEval (the bespoke evaluation controller), the flip.nvflare.executors package (RUN_TRAINER / RUN_VALIDATOR / RUN_EVALUATOR) and EvaluationPTModelLocator (the multi-model COLLECTION locator paired with ModelEval) were left behind unwired - no shipped template or config references any of them since the Client-API templates took over, and git archaeology confirmed no external consumer. Their tests go with them. Referencing prose is reworded historically: the EvaluationJsonGenerator keying docstring labels the no-MODEL_OWNER branch as the retired controller's stored shape, the eval recipe module docstring speaks of the legacy flow in the past tense and describes the live per-model broadcast accurately (the locator serves every entry in config.json['models'] - one single-model validate task per (model, client), not 'a single uploaded model'), and the Ark+ process_tools docs now attribute the strict load to the client-side evaluator rather than the deleted locator. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…rift Review finding: production deploys the COMMITTED template JSONs (fl-apps/ baked into the flip-api image) while every unit test exercised the recipes - nothing regenerated recipe.py output and compared, so a template whose aggregator regained expected_data_kind='WEIGHTS' would pass the whole suite while every live client contribution is rejected and the 'trained' model never leaves round 0 (the dice-0.006 class of bug this PR fixes). One test per (template, artefact) now asserts the committed config_fed_server.json / config_fed_client.json / meta.json equal the recipe's export (parsed-tree comparison; the filter entries' set-derived 'tasks' lists are the one hash-seed-ordered field and are canonicalised), plus a direct assertion on the deployed artefacts that no aggregator overrides the stock WEIGHT_DIFF default. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Comment-accuracy review findings, all verified against the code before editing: - standard template README told app authors to return DataKind.WEIGHTS (now rejected by the aggregator) and to call flare.get() (does not exist) - the upload contract is FLModel(params=diff, params_type='DIFF') / flare.receive(); workflow ids aligned with the generated config. - diffusion README + LDM trainer docstring still described the deleted server-side DIFF->WEIGHTS rebuild; both now state stock diff aggregation. - flip-utils README claimed the legacy Executor job types 'remain supported' (they cannot run) and, with overview.rst, that diffusion ships a tracked custom/ (no template does); overview.rst contradicted its own JobType table; the deleted executors/ package is out of both trees. - component-fl-nodes.rst had the rename antecedent inverted (the Client-API templates were the ones briefly under *_client_api names). - required-files rot: the fed_opt demo mock, the /model/job-types docstring example, flip-ui's API-failure fallback (service + spec + docs-GIF spec + seed data) and the MAP-boundary diagram all still carried validator.py for job types that no longer require it. - .gitattributes linguist-generated globs matched nothing after the rename (repointed at the plain-name recipe outputs); .gitignore and .env.development.example referenced the removed testing/ harness; the last PATH_TO_APP leftover is gone; the gutted arkplus fine-tuning Makefile comment reads again; fl-tutorials/Makefile no longer claims Flower is pending; the spleen .env.app comment's argparse claim did not hold on the sim path; the spleen eval README table cell states per-model semantics; the spleen trainer's NUM_STEPS_CURRENT_ROUND now documents the all-epochs count the DP clip bound scales with. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
…api-migration Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk> # Conflicts: # AGENTS.md # CLAUDE.md # fl-tutorials/nvflare/image_classification/xray_classification_client_api/app_files/transforms.py # fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation/app_files/validator.py # fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation/client.py # fl-tutorials/nvflare/image_synthesis/latent_diffusion_model/app_files/trainer.py # fl-tutorials/nvflare/image_synthesis/latent_diffusion_model/app_files/transforms.py # fl-tutorials/nvflare/image_synthesis/latent_diffusion_model/app_files/validator.py
An OK result with no DXO payload makes _report_data_kind_mismatch's probe raise; the new test pins the contract that the failure is demoted to a debug log and never blocks acceptance — the aggregator stays the authority on rejection. Closes the 2-line patch-coverage gap Codecov flagged. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
8 tasks
garciadias
approved these changes
Aug 18, 2026
10 tasks
10 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes the NVFLARE Client-API migration tracked by #665: ports flip-fl-base#116 (spleen tutorial → Client API), then retires the legacy Executor syntax entirely — the Client-API tutorials, templates and job types take over the plain names, converging NVFLARE's job-type naming with Flower's.
1. Spleen tutorial migrated to the Client API (flip-fl-base#116)
fl-tutorials/nvflare/image_segmentation/3d_spleen_segmentation(Client API):job.pywiresFlipFedAvgRecipe(FLIP server components, analytics bridge,VAL_DICEbest-model selection); a singletrainer.pyreplaces theFLIP_TRAINER/FLIP_VALIDATORexecutor pair (train + evaluate tasks); metrics rideSummaryWritertags throughFlipAnalyticsBridge;models.pystays checkpoint-compatible with the evaluation tutorial; the trainer fails loudly when no image/label pair is found, naming the data-enrichment step.FedAvgRecipeport (client.py/job.py/model.pyat the old tutorial root) is superseded and removed.2. Legacy Executor tutorials retired; Client-API tutorials renamed to the plain names
xray_classification,3d_spleen_segmentation,3d_spleen_segmentation_evaluationandlatent_diffusion_model; the*_client_apidirectories were renamed onto the plain names (preserving the legacy dirs' unique assets: xray + spleen bundle-export configs, the spleenutils/download tooling +pyproject.toml).testing/harness (app_organiser.sh+ compose simulator) and thetest-templatetarget are removed — every tutorial now runs via itsjob.pyon SimEnv (make run→make sim) from the flip-utils venv.3. Job types renamed:
*_client_api→ the plain names (breaking)fl-apps/nvflare/{standard,evaluation,diffusion_model}are now the recipe-generated Client-API templates (legacy Executor templates deleted); the aggregaterequired_files.jsonis regenerated —standardno longer requiresvalidator.py,evaluationnow requiresmodels.py.evaluation_client_apiin flip-api's results handling andstandard_client_apiinflip.export.bundle's exportable set.config.jsonstill says"job_type": "standard_client_api"/"evaluation_client_api"is now rejected (not in the manifest). In-tree apps (incl. the Ark+ tutorials) are updated; the FLIP-Ark repo's app configs need the same one-line change before the next stag deploy.4.
fed_optmigrated to the Client API tooflip.nvflare.recipes.FlipFedOptRecipe(+FlipFedOptShareableGenerator, stockPTFedOptModelShareableGeneratorextended to source the model from the user'smodels.get_model). This is the layout NVFlare's own Client-APIFedOptRecipe(nvflare.app_opt.pt.recipes.fedopt) wires — stock SAG +WEIGHT_DIFFaggregator + the FedOpt shareable generator, with the upstream note "FedOpt is only implemented for TransferType.DIFF and DataKind.WEIGHT_DIFF in the aggregator" — with FLIP's forked SAG and server components swapped in, exactly asFlipFedAvgReciperelates to upstream'sFedAvgRecipe. The client contract is identical tostandard, so everystandardapp runs underfed_optunchanged;required_filesdropsvalidator.py.lr=1.0,momentum=0.6, no scheduler) rather than the retired template's Adam-at-0.5 — that setting was never live (see the guard bug below) and, once the guard is fixed, demolishes the global model in a single step (verified: round-1 Dice pinned at 0.0000 under it).flip.nvflare.controllers.ScatterAndGather: the FedOpt guard in_diff_to_weightstestedisinstance(self.aggregator, PTFedOptModelShareableGenerator)— never true for any real config (that class is a shareable generator, not an aggregator) — so the DIFF→WEIGHTS reconstruction ran unconditionally and fed the FedOpt aggregator full weights it then rejected: the legacyfed_optjob type never actually aggregated. The fixed guard keys on the aggregator'sexpected_data_kind, handling both its shapes (plainDataKindwhen hand-constructed, the{dxo_name: DataKind}dict it is normalised to at runtime — the subtlety that made the first fix attempt miss). The unit test that mirrored the bug (its stub subclassed the generator to satisfy the isinstance) is rewritten against the realistic dict-form aggregator.make build-fl), the standard flip-utils shipping path.5. Aggregation aligned with stock NVFLARE: the DIFF→WEIGHTS bridge is gone
Git archaeology (prompted by review discussion) showed
ScatterAndGather._diff_to_weightswas never a design requirement: it came in with the pre-open-source import as a bridge between DIFF-sending trainers and the legacy templates'WEIGHTS-expecting aggregator, and the later "Fix fed-opt issues" commits tried (and failed — the isinstance guard) to carve FedOpt out of it. Stock NVFLARE needs none of this: the aggregator's stock default expectsWEIGHT_DIFF, averages the diffs directly, andFullModelShareableGeneratoradds the average onto the global model — mathematically identical for FedAvg (avg(g+dᵢ) = g+avg(dᵢ)) and natively partial-safe for frozen-backbone head-only updates (keys absent from the diff keep their global value — the exact guarantee FLIP#684's reconstruction existed to provide)._diff_to_weightsis deleted; every template (standard,evaluation,diffusion_model,fed_opt) now wires a bareInTimeAccumulateWeightedAggregator()(stock default) and aggregates client diffs directly. The FedOpt-specific guard becomes unnecessary and is gone with it.aggregation_weightsinto the aggregator (neverexpected_data_kind), so the platform path is untouched.make build-fl) whenever flip-utils changes, which is the standard shipping path, so this is the normal release flow rather than an extra step._client_apistrings (simulator workspace defaults in the tutorials'job.py/Makefiles and the round-metrics script) are renamed away.--workspace ../../testing/runs/workspacetojob.py, pointing into the removed harness dir — the override is dropped sojob.py's/tmp/nvflare/<name>default applies, like every other tutorial.Verification
Unit/static:
fl-apps/check_required_files.shregenerated + clean; ruff clean across the touched trees (the NVFLARE tutorial tree's pre-existing lint debt went away with the legacy files); flip-utils 732 unit tests (incl. newFlipFedOptRecipetests + the rewritten SAG guard test), flip-api 1501, fl-api-base 149, imaging-api untouched-by-this-PR.Local simulator (RTX 5090, MSD spleen):
make -C fl-tutorials run-tutorial TUTORIAL=3d_spleen_segmentation): clean end-to-end run at the committed config.FlipFedOptRecipedriven with the spleen tutorial's unmodified app_files (2 rounds): zero aggregator rejections and healthy dynamics — round-0 VAL_DICE 0.012→0.031, a normal post-aggregation dip (0.018) with recovery, final global TEST_DICE 0.0167. Under the old guard the same run loggedexpected WEIGHT_DIFF but got WEIGHTSand rejected every contribution.make exportoutput for the renamedstandardtemplate matches the committed configs exactly.Stock-aggregation verification — every job type, zero aggregator rejections everywhere:
standardfed_optFlipFedOptRecipe, 2 roundsaggregate_only_regex, 2 roundsdiffusion_modeltrain_ae+ 18train_dmtasks)evaluationevaluation(multi-model)standard+SERVER_CHECKPOINT+ head-onlyAGGREGATE_ONLY_REGEX=omni_heads)standard)RESULTS_UPLOADED, final-globalval_acc0.0602 (model2149843f), best+final checkpoints in the zip, zero rejections on the live fl-serverDev-stack e2e (platform path, GSTT, NVFLARE backend):
val_acc0.0063 vs Client-API 0.5217 (local sim: 0.060 vs 0.1875), withbest_FL_global_model.ptpresent in the Client-API results zip.fl-apps: the renamed spleen tutorial uploaded with"job_type": "standard"went through upload → scan → bundle (new manifest) → train →RESULTS_UPLOADED(model5de08634), final-globalval_acc0.0132 at the committed 2-round config, withbest_FL_global_model.ptin the results zip (the fl-api's submit-time selector injection works through the renamed manifest).Multi-agent review round (applied in-branch)
A six-pass review (code, tests, comments, silent failures, type design, security) ran against this branch; every confirmed finding is fixed in the six commits after
faf2aff2:state_dict()as the global and never syncs from the persistor, so round 1 silently replaced the global (anySERVER_CHECKPOINTbackbone included) with random init + one step. The generator now syncs its copy from the live global in place before each step (optimizer state preserved via tensor identity), resolvessource_modelatSTART_RUNundersystem_panic(the dispatcher swallowshandle_eventexceptions), and the recipe gained stock'sensure_config_type_dictnormalisation + isolated defaults + a construction-time shape check.BEFORE_AGGREGATION(previously: empty aggregates applied as no-ops to a COMPLETED "trained" model), a wrong weights kind relays an actionableparams_type='DIFF'message to the hub, and every failed client task is relayed (pointed fallback naming the trust-side logs when no exception header exists — full traceback transport is Restore end-to-end client exception messages to the hub for Client-API tasks #987).*_client_apialiases actually work now — they were unreachable dead code (manifest validation rejected them first); both bundlers normalise them before validation,UnknownJobTypeErrornames the valid set + fix, and the contract is pinned unmocked against the real manifest.ModelEval, theexecutors/package andEvaluationPTModelLocator(all unwired) deleted with their tests; referencing prose reworded historically.WEIGHT_DIFFdefault.DataKind.WEIGHTSreturn and nonexistentflare.get(), the.gitattributeslinguist-generatedglobs that matched nothing post-rename, andvalidator.pylingering in the UI fallback/mock/docs contracts.The security pass found no vulnerabilities introduced by the PR (the one candidate sink — user-steerable server-side
instantiate_class— was traced end-to-end and is not reachable from user input).Issues found and split out during verification
dcm2niixpinned to a stale 2021 Docker Hub build silently drops slices from 6/21 spleen studies (draft PR up, CI green).Ticks the flip-fl-base#116 box on #665 and resolves its "retire the legacy
standardExecutor job type once parity is confirmed" consideration — parity was confirmed and the legacy syntax retired. The XGBoost port (flip-fl-base#114) remains the last open #665 item.