A framework to experiment with hierarchical federated learning (edge → fog → … → cloud): trees of any depth, fogs that mix datasets, pluggable learning techniques, a virtual-clock simulator and real runs over MQTT, with every level instrumented. It grew out of a stress-detection project on wearable and workplace data (SWELL, SWEET, WESAD) and is a master's thesis (TFM) prototype.
About this README. It describes the redesigned framework as of v0.4.0 (October 2026), which adds federated learning techniques (personalisation, drift correction, robust aggregation and privacy, server optimizers and buffering) to the Onion-FL Studio of v0.3.0 and the framework of v0.2.0. Every number in it comes from a file in the repository, cited next to it. The datasets are not in git. SWELL-KW and WESAD were downloaded and checked against their descriptors in October 2026, and the first results of the new framework on real data are in §7. SWEET is still unchecked. §1 says exactly what is verified and how.
- Status
- Quick start
- How it works
- Experiments
- Datasets
- Observability
- Results
- Repository map
- Development and CI
- Known limitations
- Roadmap
- Project history
- License and acknowledgments
| Area | Status | Summary |
|---|---|---|
| Topologies | ✅ | Trees of any depth in YAML (compact or general form), each with a topology_id (SHA-256 of its structure and links) and a JSON/Mermaid graph |
| Data | ✅ / |
Declarative ingestion (readers and steps), a signed cache, subject roles fixed across scenarios, and placement plugins. FEMNIST (LEAF, one subject per writer, 200 fixed writers) adds a dataset with hundreds of natural clients, Fed-Heart-Disease (FLamby, four hospitals with their official test splits) a cross-silo one, and CIFAR-10 one with synthetic clients (iid, Dir(α) or k classes each, as NIID-Bench) and its official test batch. The SWELL, WESAD, Stress-Predict, FEMNIST and Fed-Heart-Disease descriptors are checked against the published files (and against the loaders from before the redesign, or FLamby's code for Fed-Heart-Disease); SWEET is not (§5) |
| Learning | ✅ | Two model families with the same namespaced parts (adapter per dataset, shared or per-dataset trunk, head per task or dataset): modular_mlp and feature_transformer (one token per feature, attention trunk). Sharing scopes: global, per level, local. Per-key aggregators and server optimizers; trainers standard, fedprox; for personalisation ditto, apfl, fedrep, fedbabu and the lg_fedavg sharing preset; for non-IID drift scaffold, moon, and the trainer + server optimizer pairs fednova and feddyn. Algorithms exchange extra state (control variates) as auxiliary arrays, and the server optimizer gets each round's statistics (steps, edges). Robust aggregators (krum, multi_krum, bulyan, geometric_median, norm_clip), central and local differential privacy (dp_fedavg, local_dp) with ε per round, and an attack axis (label_flip, sign_flip, gaussian, scale) on a seeded fraction of edges. Edges also score personal and fine-tuned models; random or checkpoint init |
| Round protocol | ✅ | Coordinator, aggregators at any level, edges and evaluators. Registration with acknowledged hello, quorum and deadline, staleness and participation plugins, per-key weights, evaluation at edge, zone and global level |
| Simulation | ✅ | Virtual clock; links with latency, jitter, bandwidth and loss; compute and availability models; deterministic for a seed |
| Real runs | ✅ | One process per aggregator over MQTT; a manual onion_fl node start for several machines. Verified locally against Mosquitto: 3 processes, every message delivered, latencies measured, and the same final model as the simulation |
| Observability | ✅ | runs/<run_id>/ with signed events, summary and final model. Diagnostics at every aggregator; analysis API with mean ± CI over seeds; HTML report; Prometheus and OpenTelemetry sinks; Grafana dashboard |
| CLI | ✅ | onion_fl data · topology · plan · run · node · ceiling · fidelity · calibrate · report · baseline · schema · serve |
| Studio | ✅ | onion_fl serve: the topology library and editor; experiments with their plan and launch; a live run monitor; comparisons between topologies and scenarios per level; and a tutorial with dry-run previews (§6) |
| Continual learning | ✅ / |
Every simulated run writes a signed bundle, and a later run continues it exactly (init: run). Edges can be fed by streams: rows in time order, a labelled fraction, delayed labels, test-then-train scoring, a replay memory (none, fifo, reservoir, class_balanced), and objectives on the rows still waiting for their label (pseudo_label, consistency), with an audit of every pseudo-label. Triggers open the rounds (schedule, volume, drift, any), with drift detected per edge, zone and federation. A promotion policy keeps each round's model as a challenger until the validation streams promote it, with rollback. Each stream edge updates warm, frozen or scratch, the baselines of the continual benchmark, and scripts/continuum_benchmark.py measures it. Streams and continual runs also run on real processes; on the distributed backend each edge keeps its state on its machine and resumes it when its host restarts, and hosts may join late (C9, so far on containers acting as machines). The Studio shows the versions and their lineage, and its signed controls pause, resume, promote and roll back a live simulation. |
| gRPC and Flower transports, distributed deployment | ❌ | Planned: gRPC #196, Flower #204, distributed backend #199 (they replace E5 #104 and E6 #105) |
| Tests | ✅ | 1978 tests. With the datasets in data/, a local broker and Docker, 1970 pass and 8 skip: SWEET (2), the optional Parquet reader and the container runs of the emulated and distributed backends (5), which need the emulated host image. The CI starts a broker but has no data, so the data-dependent tests skip there |
- Eighteen experiments of the new framework have run on real data, all committed in §7. They are the SWELL reference run, a SWELL + WESAD mixing sweep, the comparisons of personalisation, drift, robustness, privacy and server-side techniques, the comparison of two model families, the continuation of a trained model, two stream replays of SWELL + WESAD (without and with a replay memory), the triggers on those streams, the selection of the unlabelled objectives (a null result: nothing could be chosen), the continual benchmark (five baselines chosen on validation, then run on test, with sweeps and a promotion run), a FedAvg reference on 200 FEMNIST writers, FLamby's triangle on Fed-Heart-Disease, FedAvg with an MLP on CIFAR-10 (Dir(0.5) and IID; the NIID-Bench CNN reproduction waits for #245), and the same federation over four simulated architectures of the continuum. Most have three seeds (Fed-Heart-Disease and the architectures have five, and the architectures' times come from an uncalibrated infrastructure), so many intervals overlap. A pre-registered pilot on FEMNIST (
sim, 390 runs) tests whether the factorised predictor picks the wrong federation when a deadline couples participation and data weight.- The reference run and the mixing sweep have no strong result. On SWELL, no model beats always predicting "stress" on the held-out subjects. On WESAD, the federated model reaches 0.77 accuracy where centralised logistic regression reaches 0.91.
- In the technique comparisons, the clearest effects are on WESAD: FedDyn and SCAFFOLD beat FedAvg under drift, the adaptive server optimizers beat it too, and a strong sign-flip attack collapses FedAvg while norm clipping holds.
- On the streams, no continual baseline learns to tell the classes apart on held-out subjects: every model that keeps updating ends predicting one class. The baselines differ only in how fast they follow the class that is arriving, and replay and the unlabelled rows add nothing measurable at a tenth of the labels.
- The multi-dataset stress benchmark (onion-bench-stress, §7) ran on four E4 studies plus SWELL + WESAD with five seeds. Plain FedAvg beats the centralised logistic regression on no E4 study; FedBABU and FedDyn beat it on Stress-Predict and narrowly on PhysioNet; on Nurse Stress nothing is far from the majority class, and a model trained only on the laboratory studies transfers poorly to the hospital.
- Checking the data found real problems, now fixed. 999 means "missing" in the SWELL physiology file, for 53% of the heart-rate values, and both the old and the new loaders read it as a value. The facial and physiology joins and the WESAD chest signals
RespandTempfailed to load. The SWELL posture file has every date a month early, so posture is left out. - Tests that need data skip without
data/(docs/RULES.md). Protocol tests use a stub trainer that learns nothing. - Legacy numbers stay legacy. §7 lists the baselines from before the redesign with their caveats. Some of them predate the
blokleak fix. - The real-data-only rule holds. Two old preprocessing scripts once wrote
np.randomvalues under the real SWELL file names. Both are deleted; if either ran on your machine, restoredata/SWELL/from the original download. The dataset card (onion_fl data inspect) flags any feature whose correlation with the label is above 0.95.
Python 3.11. The repository uses a .venv created with uv:
uv venv .venv --python 3.11
uv pip install --python .venv torch --index-url https://download.pytorch.org/whl/cpu
uv pip install --python .venv -e ".[dev]" # add ",analysis" for XGBoostOr run just install-dev. The command line is onion_fl (also python -m onion_fl).
The data is not in git. Download each dataset from its source and place it where its descriptor in datasets/ expects it:
| Dataset | Source | Path | Descriptor |
|---|---|---|---|
| SWELL-KW | DANS, open access: the 11 files of 3 - Feature dataset/per sensor (18 MB; download the two .tab files in their original format, CSV) |
data/SWELL/3 - Feature dataset/per sensor/ |
datasets/swell.yaml (computer file; facial and physiology as options), datasets/swell_physiology.yaml |
| SWEET | — | data/SWEET/{sample_subjects,selection1/users,selection2/users}/<user>/ |
datasets/sweet.yaml |
| WESAD | University of Siegen (WESAD.zip, 2.2 GB; publications must cite Schmidt et al., 2018); extract the S<n>/S<n>.pkl files (13 GB) into data/ |
data/WESAD/S<n>/S<n>.pkl |
datasets/wesad.yaml, datasets/wesad_e4.yaml (wrist at the native E4 rates, with the harmonized E4 features) |
| PhysioNet Wearable Device | PhysioNet ("Wearable Device Dataset from Induced Stress and Structured Exercise Sessions", v1.0.1, ODC-By 1.0, open access; cite the dataset and PhysioNet): the official zip (wearable-device-dataset-1.0.1.zip, 73 MB; 259 MB extracted), checked against the project's SHA256SUMS.txt, or wget -r -N -c -np https://physionet.org/files/wearable-device-dataset/1.0.1/. Extract the contents of its top folder into data/PhysioNetE4/ |
data/PhysioNetE4/Wearable_Dataset/{STRESS,AEROBIC,ANAEROBIC}/<session>/*.csv |
datasets/physionet_e4.yaml (Empatica E4 at native rates, harmonized E4 features; one subject per participant) |
| Nurse Stress | Dryad or the same files on Zenodo (Hosseini et al., 2022, Sci. Data 9:255), CC0: redistributable, so a real extract may be shared. Download Stress_dataset.zip (1.16 GB, MD5 8829a9c0…210cea) and SurveyResults.xlsx; the zip holds one zip per E4 session, which python -I scripts/nurse_stress_extract.py data/NurseStress/Stress_dataset.zip data/NurseStress/Stress_dataset unpacks (3.6 GB, 609 sessions). Reading the survey needs openpyxl (extra excel). scripts/nurse_stress_sample.py writes a 9 MB real extract (four sessions of four nurses) to data/samples/NurseStress/ |
data/NurseStress/{SurveyResults.xlsx,Stress_dataset/<ID>/<ID>_<start>/} |
datasets/nurse_stress.yaml (survey events on E4 windows; the time zone and the labels are in its header) |
| Stress-Predict | GitHub (MIT; cite Iqbal et al., 2022, Sensors 22:8135): download the repository archive (Download ZIP, 24 MB) and extract its folders; reading Time_logs.xlsx needs openpyxl (in the dev extra). scripts/stress_predict_sample.py copies three sessions, with the licence, into data/samples/stress_predict/ for the tests |
data/StressPredict/{Raw_data/S<n>/*.csv,Processed_data/Time_logs.xlsx} |
datasets/stress_predict.yaml (34 subjects, harmonized E4 features) |
| FEMNIST | LEAF (Caldas et al., 2018; BSD-2): ./preprocess.sh in leaf/data/femnist downloads NIST SD19 and first writes data/all_data/all_data_<k>.json, every writer before LEAF's sampling (about 3,600); copy those files. A shorter route is the Flower Datasets copy, flwrlabs/femnist (one 201 MB Parquet file): scripts/femnist_from_flower.py writes the 200 declared writers in the same format (it needs pyarrow and Pillow, not dependencies of Onion-FL; the command is in the script) |
data/FEMNIST/all_data/*.json |
datasets/femnist.yaml (200 fixed writers) |
| Fed-Heart-Disease | UCI Heart Disease (Janosi et al., 1988; CC BY 4.0, doi:10.24432/C52P4X), the four files FLamby's download.py fetches: processed.{cleveland,hungarian,switzerland,va}.data from https://archive.ics.uci.edu/ml/machine-learning-databases/heart-disease/ (40 KB) into data/HeartDisease/raw/. scripts/heart_disease_flamby.py checks FLamby's MD5 sums and writes FLamby's preprocessing and train/test split, one CSV per center and split (the command is in the script) |
data/HeartDisease/flamby/<center>{,_test}.csv |
datasets/heart_disease.yaml (4 hospitals, FLamby's split) |
| CIFAR-10 | University of Toronto, the python version (cifar-10-python.tar.gz, 163 MB, MD5 c58f30108f718f92721af3b95e74349a). The page gives no licence; it asks that work using the dataset cite Krizhevsky (2009), Learning Multiple Layers of Features from Tiny Images. Check the MD5 and extract it into data/CIFAR10/. The batches are pickles: the reader opens them with a restricted unpickler that runs no code from the file |
data/CIFAR10/cifar-10-batches-py/{data_batch_1..5,test_batch} |
datasets/cifar10.yaml (synthetic clients) |
FedScale's client device traces (issue #253; FedScale, Apache-2.0) are infrastructure, not data to learn from. Download client_device_capacity and client_behave_trace from benchmark/dataset/data/device_info/ (65 MB) into data/FedScale/ and run onion_fl traces import fedscale data/FedScale. They are pickles: the import reads them with an unpickler that builds only plain values and writes capacity.csv, behaviour.jsonl and manifest.json, which is all the runs read. infrastructures/fedscale_field.yaml samples its phones from them.
onion_fl data inspect swell # dataset card: subjects, classes, missing values, leak warnings
onion_fl topology show topologies/four_fogs.yaml
onion_fl plan experiments/mix_ab.yaml # composition per fog and groups per link, no training
onion_fl run experiments/mix_ab.yaml --workers 3
onion_fl report experiments/mix_ab.yaml --by topology_id --by scenario
onion_fl baseline experiments/mix_ab.yaml # LR, RF, XGBoost on the same test subjects
onion_fl baseline experiments/mix_ab.yaml --seeds 0 1 2 # each seed draws other test subjects; mean and 95% CI
onion_fl explain runs/<run_id> # feature importance on val subjectsFor a real run over MQTT, start the stack (just docker-up, docker/README.md), point the links at the broker and run onion_fl run <experiment> --backend local (--mode real is its alias). Links with transport: grpc need no broker (uv pip install -e ".[grpc]", already in dev), and neither do links with transport: flower, which carry each frame as a Flower message (uv pip install -e ".[flower]", Flower >= 1.21; not in dev).
With Docker, --backend emulated runs the same processes in one container per host of the experiment's infrastructure, with its CPU and memory limits and its links emulated by netem (docs/architecture.md, §7). The first run builds the host image, which takes a few minutes.
On real machines, --backend distributed with an inventory of them (SSH address, user and key path) checks each one first (the same onion_fl source, the data, the clock against the launcher or NTP, the broker and the gRPC ports), starts one process per host over SSH and brings the events back (docs/architecture.md, §7; hardware still pending: docs/distributed-hardware.md).
The simulation's compute rates and links can come from measurements: onion_fl calibrate measure <spec> runs microbenchmarks (training per profile and model, aggregation, MQTT paths) in that emulation, calibrate fit turns several sessions into a signed calibrations/<calibration_id>/ that profiles and links cite with calibration: <id>, each value with the 95 % interval one more session's value falls in, and calibrate check judges a session the fit never saw against it. A run whose rate for a model had to be estimated by FLOPs says so in run.json (estimated), as does one that sends past the sizes its calibrated paths hold (outside_calibration), and onion_fl fidelity refuses both (docs/architecture.md, §7a). The emulated lab's calibration b55104b3… (results/calibration_emulated_pinned), fitted on four pinned sessions, passes every quantity on two check sessions of its day, and 49 of 54 per session three days later, when the machine computed about twice as fast; its compute intervals span a factor of 2.6 to 26, the network's a few tenths of a millisecond.
uv pip install --python .venv -e ".[studio]" # FastAPI and uvicorn (already in dev)
onion_fl serve # http://127.0.0.1:8765The Studio reads and writes the same files as the command line (topologies/, experiments/, runs/). It has five areas:
- Topologies: the library, the graph and an editor that validates as you type.
- Experiments: scenarios, the dry-run plan (composition per fog, groups per link, a stream's pace, warnings) and a launch button.
- Runs: status, identity, the rounds and their triggers, the drift detected, metrics per level and live events; with versions, their lineage and scores, the rows and drift per edge, and the operator's controls.
- Compare: mean ± CI over seeds, by topology, scenario or dataset.
- Tutorial: every plugin explained, with previews of sharing, placement and network profiles.
The full design is in docs/architecture.md, and how it compares with Flower, FedML, FedScale, Fogify, EmuFog, iFogSim and EdgeSimPy is in docs/related-work.md. In short:
flowchart TD
cloud["cloud<br/>global · coordinator"]
fog_a1["fog_a1<br/>fog · aggregator"]
fog_a2["fog_a2<br/>fog · aggregator"]
fog_b1["fog_b1<br/>fog · aggregator"]
fog_b2["fog_b2<br/>fog · aggregator"]
fog_a1 -->|mqtt/json/wifi| cloud
fog_a2 -->|mqtt/json/wifi| cloud
fog_b1 -->|mqtt/json/wifi| cloud
fog_b2 -->|mqtt/json/wifi| cloud
edges_fog_a1(["edges (edge)"]) -->|mqtt/json/4g| fog_a1
edges_fog_a2(["edges (edge)"]) -->|mqtt/json/4g| fog_a2
edges_fog_b1(["edges (edge)"]) -->|mqtt/json/4g| fog_b1
edges_fog_b2(["edges (edge)"]) -->|mqtt/json/4g| fog_b2
topologies/four_fogs.yaml, drawn with onion_fl topology show --mermaid; the graphs are in docs/diagrams/.
| Layer | What it does |
|---|---|
core |
Message and codecs (json, npz), Node (a state machine) and Context (its only way out), topologies and identifiers, plugin registries |
data |
Descriptors → cache → SubjectData per subject → roles (test, val, train, local_val, clients, scaling fitted on train only) → placement under the leaf aggregators |
learning |
Modular model with namespaced keys (adapter.<ds>, trunk, head.<task>), sharing scopes, aggregators, server optimizers, trainers, inits, metrics |
roles |
Coordinator, aggregators and edges. Every round sends each link only the groups its scope lets through; weights travel per key, so a tree of FedAvg equals a flat FedAvg |
runtime |
SimRuntime (virtual clock) and RealRuntime (wall clock); both drive the same nodes |
transports |
memory, mqtt (one inbox per node, QoS and broker per link) grpc (direct links, no broker; grpc extra) and flower (the same links, each frame in a Flower message; flower extra) |
observability |
Events, run identity and signature, diagnostics, analysis, report, sinks |
experiment |
Config, sweeps, the dry-run plan, the runner and the CLI |
Every experimental axis is a plugin chosen by name in the config, and new ones are registered without touching the core: placement, transport, codec, aggregator, trainer, server optimizer, sharing, model, init, metric, diagnostic, participation, staleness and sink. onion_fl schema exports the config's JSON Schema with the catalogue of every plugin and its parameters.
An experiment names a topology, the data, the learning setup, the rounds, the evaluation and the runtime, plus seeds and a sweep. experiments/mix_ab.yaml takes SWELL and SWEET over four fogs from segregated (α = 0) to fully mixed (α = 1):
name: mix_ab
topology: four_fogs
data:
datasets: {swell: {}, sweet: {options: {label: binary}}}
roles: {test: 0.2, val: 0.1, local_val: 0.2, scaler: global, seed: 0}
placement: {name: mixing, alpha: 0.0}
learning:
model: {name: modular_mlp, adapter_width: 64, trunk_hidden: [64, 32]}
sharing: fedavg
trainer: {name: standard, local_epochs: 1, lr: 0.001}
rounds: 20
evaluation: {metrics: [loss, accuracy, macro_f1], edge: {every: 5}, aggregators: {every: 5}, global: {every: 1}}
seeds: [0, 1, 2]
sweep: {data.placement.alpha: [0.0, 0.5, 1.0]}-
Scenarios. Each combination of the swept values, times each seed, is one run. Its
config_idhashes the validated config without the seed, so the seeds of a scenario group together. -
Subject roles. They use their own seed, so the test subjects are the same in every scenario and seed.
-
Validation. Errors name their exact path, for example
learning.trainer: plugin 'standard': invalid parameters: lr …. -
Paired values. A key
a,bsweeps several paths together:learning.sharing,learning.trainer.name: [[fedper, fedrep], [fedavg, ditto]]gives one scenario per pair. A whole plugin can be a value; its scenario is namedname(k=v,…). -
Random search.
search: {budget: 16, seed: 0, space: {learning.trainer.lr: {low: 1.0e-4, high: 1.0e-1, log: true}, learning.trainer.local_epochs: [1, 2, 4]}}turns each swept scenario intobudgettrials, drawn from the search's own seed; a path is either swept or searched. Each trial keeps the search seed and its index, so they are part of itsconfig_id. Unset, it stays out of theconfig_id. -
Noise ceiling.
onion_fl ceiling <experiment> --replicas Rruns R replicas of every swept scenario, each with its own seed (--pairedgives the scenarios of a replica the same seed), and with a search keeps each one's best trial (--resamplealso redraws the search per replica). It prints the top-1 agreement and the selection regret between replicas, with the agreement expected by chance (1/K). The score is the last--metricof the root, pooled over datasets unless--seriespicks one, and the trials are chosen on that score too: for a search, setevaluation.global.subjects: val. -
Fidelity.
onion_fl fidelity <runs A> <runs B>compares the runs of a backend under test (B, for examplesim) with those of a reference (A, for examplelocal) on the same experiment, pairing them by configuration and seed. It reports functional fidelity (final model, protocol events per node, bytes per link), the relative error of round, compute and link times with bootstrap intervals, and decision fidelity per--metric(Kendall's τ-b, top-1 agreement, selection regret, the probability of top-1 agreement from a bootstrap paired by seed), each next to the noise ceiling (--ceilingwith the output ofonion_fl ceiling, or the seeds of A). Ontimeandtime_to_targetit also shows how the factorised predictor (rounds to target in a synchronous control run × time per round from the compute and network models, plain and with a FLOSS-style participation correction) decides against A;onion_fl fidelity predictor <runs>makes the predictor a side itself, and--controlnames the control runs. It warns when every configuration ties on a metric.--csvand--htmlwrite the report. -
Aggregator, attack and privacy.
learning.aggregatorsets the aggregator of the leaf aggregators (the fogs over the edges).attackmakes a seeded fraction of each dataset's edges malicious, andprivacyadds local DP at every edge. Unset, they stay out of theconfig_id. -
Server optimizer.
learning.server_optimizersets the root's optimizer. Pairs such asscaffold,fednovaandfeddynare checked against the trainer before the first scenario runs. -
Buffering and selection.
close_at_quorum: trueon an aggregator closes its round as soon as itsquorumis in, late updates included. Withstaleness: next_roundthis gives hierarchical buffering inspired by FedBuff (experiments/fedbuff.yaml); the cloud stays synchronous.evaluation.global.subjects: valscores the global model on the validation subjects instead of the test ones, for choosing server-side hyperparameters.
-
Continuing a run.
learning.init: {name: run, run: <run_id> | experiment:<name>[/<scenario>], restore: {...}}continues a finished, signed run from its bundle (runs/<run_id>/bundle/): global model, server state, edges, random streams and frozen preprocessing; the round numbering goes on and the parent is recorded inrun.json. A continuation that cannot be exact is refused. -
Streams.
stream: {bootstrap, round_every, batch_size, speed, start}andlabels: {fraction, delay}replay each training subject's rows to its edge in time order.- The bootstrap (the first minutes, or rows) is history: it fits the preprocessing and trains v0.
- A seeded fraction of the rows is labelled, each label arriving after the delay.
- Every arrival is scored by the model the edge was serving before it can train (
prequentialandprequential_labelled), and each round trains on what became trainable since the edge's previous round. - Validation subjects stream for evaluation only; test subjects never reach an edge.
- Rounds come every
round_everyof data time until the streams end, or when a trigger fires (below), anddata.arrivedanddata.labelledrecord the volume. - Real mode,
init: run,local_val, a local scaler and participation other thanallare refused with a stream for now.
-
Replay.
continual: {memory: {name: reservoir, capacity: 512}, replay_ratio: 0.5}gives every streaming edge a memory of rows it already trained on.- Each training adds a sample from the memory to its new rows, so that a fraction
replay_ratioof it is replayed. - A row enters the memory only after its first successful training. The edge is still weighted by its new rows, and
memory: noneruns exactly as withoutcontinual.
- Each training adds a sample from the memory to its new rows, so that a fraction
-
Unlabelled rows.
unlabelled: {name: pseudo_label, threshold: 0.9, weight: 0.5}(or{name: consistency, noise: 0.1}) lets every streaming edge learn from the rows whose label has not arrived.- The objective sees their features only, with the model as received, and adds a weighted term to each labelled batch. An edge with no new labelled rows stays idle.
diagnostic.unlabelledreports the rows seen and accepted, the confidence and, in simulation, the accuracy of the pseudo-labels; the bundle keeps each one's class, confidence and the round whose model generated it.- Ditto, APFL and FedRep are refused (their own loops add no term), and so is
unlabelledwithout a stream.
-
Triggers.
continual.triggerreplacesstream.round_every:continual: trigger: {name: any, of: [{name: schedule, every: 30m}, {name: drift, kind: prior}]} edge_trigger: {name: volume, samples: 64} # an edge trains once it has 64 new rows status_every: 5m
- Edges report what became trainable and their drift statistics every
status_every, without data. The fogs pool them, and the coordinator opens a round when its trigger fires, counting what was reported after the current round reached each edge. - The local trigger never holds back an edge's first model (v0) or its rounds after its last label.
- Drift (
data,priororperformance) is detected per edge, per zone and for the federation, and reported asdrift.detected.trigger.firednames what opened each round. - A schedule must be a multiple of
status_every. One whoseeveryandstatus_everyequal the oldround_everygives C3's model, as long as each round closes within its period. run.finishedsays whether the last label (horizon) or theroundscap ended the run.- Before M1 (#193) the triggers were a
continuumblock of their own. It still loads for one version, with a deprecation warning, and gives the sameconfig_id: the committed experiments keep it, so their scenarios keep their names. The packageonion_fl.continuumis nowonion_fl.continual, with the old name as an alias.
- Edges report what became trainable and their drift statistics every
-
Champion and challenger.
promotionkeeps each round's model as a challenger until the validation streams decide on it:promotion: {name: champion_challenger, metric: macro_f1, min_delta: -0.01, max_forgetting: 0.03, min_samples: 500}
-
The edges serve the champion only. A validator per validation subject, under the root, scores both on the labels that came since the champion was promoted (or within
window), and on every earlier one, the history included. -
The challenger is promoted unless it loses more than
min_deltaon the recent window, forgets more thanmax_forgettingon the earlier labels, or has fewer thanmin_samplesrecent labels to be judged. A rejection leaves the champion and its server state as they were. The test subjects never decide. -
version.candidate,version.promoted,version.rejectedandversion.rollbackcarry the decision's scores. An operator's control message to the coordinator ({rollback: v},nullfor the previous champion, or{promote: v}) changes the champion. Only the configured operator ids may send it, and only the configured validators decide; a missing or non-finite score never promotes. -
Operators. A YAML file of operator ids and tokens (
alice: <16 characters or more>), named byONIONFL_OPERATORSoronion_fl serve --operators, says who may give orders. Each order is signed for its run with the operator's token, and the coordinator refuses an unsigned, forged or replayed one on any transport. Without the file, no order is taken. -
Live control. A simulation with operators reads its inbox as it runs: in the Studio, a run's page pauses and resumes it, and promotes or rolls back a version, at once or at a virtual time.
onion_fl run --paused(or "en pausa" in the Studio) starts it paused. The same controls are an HTTP API:curl -X POST localhost:8765/api/runs/<run_id>/control -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" -d '{"order": "rollback", "at": 12}'
-
The bundle's model is the champion, and its lineage lists every version.
-
-
Edge validation.
data.roles.local_val_split: class_tailholds out the last rows of each class instead of the last rows of the recording, which are usually a single condition. -
Downloadable data.
experiments/mix_swell_wesad.yamlruns the same sweep with SWELL and WESAD, the two datasets that can be downloaded. It trains for 10 local epochs: with one, the model only learns the majority class (§7).
Each run writes runs/<run_id>/:
| File | Content |
|---|---|
run.json |
Identifiers (topology_id, config_id, data_id, code_version), resolved config, host, start and end, status (running, finished, incomplete, failed), the ex ante diagnostic of the deadline bias (diagnostic, in sim) and run_hash |
events.jsonl |
Every event: messages, rounds, training, evaluation, diagnostics, data composition |
summary.json |
Rounds, traffic, failures and the last scores at the root |
model.npz |
The final global model, loadable as a checkpoint init |
run_id is <UTC date>-<sha12>, unique even when the same config runs in parallel. run_hash covers run.json, the events, the summary and the model, so any later change is detected (verify_run). A result is cited with topology_id + config_id + run_id + run_hash.
| Dataset | Label strategies | Unit | Notes |
|---|---|---|---|
| SWELL-KW | binary (N vs T, I, R), binary_no_r |
One row per minute and subject | The computer file by default; facial and physiology are optional joins (posture is left out: its file has every date a month early). 999 is missing in facial and physiology. swell_physiology.yaml is physiology only |
| SWEET | binary (stress ≥ 2), ordinal (1–5), three_class |
One self-report matched to the feature minute | selection option: sample_subjects, selection1/users, selection2/users; subjects with fewer than 5 samples are dropped |
| WESAD | binary (baseline vs stress), three_class (+ amusement) |
60 s windows, 50% overlap, five statistics per channel | location: wrist (default) or chest; signals option |
| PhysioNet Wearable Device | binary (baseline and rests vs stress tasks, task stress_binary), three_class (+ exercise, task stress_vs_exercise) |
60 s windows, 50% overlap, five statistics per E4 channel | Labels from the protocol tags; the sessions that data_constraints.txt flags as invalid, incomplete or split are dropped or cut, as the descriptor documents. The 30 s stages give no 60 s windows (window_seconds option) |
| Nurse Stress | binary (inside a survey event rated ≥ 1 vs outside every event of a surveyed E4 session), high (rated 2) |
60 s windows, 50% overlap, the harmonized E4 features (ACC, BVP, EDA, TEMP at native rates) | One subject per nurse (15), never the session; windows in unrated or level-0 events, and sessions without an event, are dropped. Survey times are local (America/Chicago, with daylight saving), the E4 is Unix UTC. The events were flagged by the authors' detector on EDA, HR and TEMP, so "no stress" is partly what it did not flag. 63,913 windows, 15% stress |
| Stress-Predict | stress_binary (Stroop, interview and hyperventilation vs baseline, rests and questionnaire) |
60 s windows, 50% overlap, the harmonized E4 features | Labels from Time_logs.xlsx (minute resolution): they agree with the authors' processed CSV on 96.3% of its seconds (INDEX). S01 is left out, as by the authors; consent and the gaps between phases have no class |
| FEMNIST | character (62 classes: 0-9, A-Z, a-z) |
One 28x28 image per row, 784 pixels in [0, 1] | One subject per writer: a fixed subsample of 200 writers (44,451 images) declared in the descriptor; the writer id is never a feature |
| Fed-Heart-Disease | heart_disease (no disease vs disease, UCI num ≥ 1) |
One patient per row, FLamby's 13 features | Cross-silo: one subject per hospital and split (cleveland and cleveland_test, …), FLamby's fixed split (train/test 199/104, 172/89, 30/16 and 85/45). The *_test subjects are the test role (docs/RULES.md §2); the center is never a feature |
| CIFAR-10 | object (10 classes) |
One 32x32 colour image per row, 3,072 pixels in [0, 1] | No natural clients: the partition step cuts the 50,000 training images into synthetic clients (iid, dirichlet_label with alpha, or label_skew with k classes each, as NIID-Bench), drawn from the labels and partition_seed only. The official test batch is the subject test, reserved with data.roles.overrides.cifar10.test: [test] (experiments/cifar10_mlp.yaml) |
Missing values stay missing in the cache. Imputation, scaling and the removal of constant features are fitted on the training subjects only. A dataset can set its own in data.roles.overrides, for example femnist: {scaler: none, drop_constant: false} to keep the raw pixels (experiments/mix_swell_femnist.yaml). A descriptor whose features are token indices declares tokens: true, and an experiment that would scale it is refused. The descriptors record in their comments what was found in the published files, for example the keys that join the SWELL modalities. tests/test_datasets_descriptors.py compares each descriptor with the loader from before the redesign, and it runs as soon as data/ is present. It passes for SWELL (computer file) and WESAD (chest, subject S2).
Every runtime event is enriched into one schema and written to events.jsonl, the source of truth:
{t_virtual, t_wall, run_id, topology_id, scenario, seed, round, level, node, role, kind, name, value, tags}
- Evaluation.
- Edges score the received and the trained model on their
local_val. - Aggregators combine their children's scores by samples, and score the zone model on their
valevaluators. - The coordinator scores the global model on the
testevaluators, per dataset.
- Edges score the received and the trained model on their
- Diagnostics at every aggregator.
- Divergence: the cosine and L2 dispersion of the children's updates per parameter group.
- Conflict between datasets: the cosine of their mean updates in the shared groups.
- Drift across rounds, participation (with late updates and time to quorum) and fairness of the edge scores per dataset.
- Traffic per link and round, which the run recorder adds.
- Analysis.
load_runs("runs/").compare(level="fog", metric="accuracy", by=["topology_id"])gives the mean ± 95% CI over seeds per round, plus the spread across the nodes of the level. The runs of a group are pooled, so for a sweep over one topology addscenarioorconfig_idtoby.onion_fl reportbuilds an HTML page from it. Withtarget=0.8,comparegives the time to target instead: the mean first instant the metric reaches it, with a bootstrap interval, and how many runs never did (censored at their duration);time_to_targethas the per-run rows. - Studio.
onion_fl servegives the same views in the browser, and follows running runs live. - Live.
- The
prometheussink servesonionfl_*series on port 9464 for the Grafana dashboard. One server per process holds the runs of a sweep, told apart byrun_id, so it needs--workers 1. With versions and streams it adds the champion, the version decisions and their scores, the rows arrived per edge and the drifts detected, in the dashboard's Continuum row. - The
otelsink creates one span per send and per receive, linked by the message id. It exports them to the collector of the Docker stack. - A real run feeds both when the launcher merges the node processes' events, once the run has ended.
- The
Only results backed by a committed file are listed.
Source: results/swell_reference/, config_id dbf79b91…, topology_id 38ee627b…, commit 1696c05. It is experiments/swell_reference.yaml: physiology only, three fogs, 18 training subjects and 5 held-out test subjects (646 minutes, 67.3% stress), 12 rounds, seeds 0–9.
| Model | Accuracy | Macro-F1 | Balanced accuracy |
|---|---|---|---|
| Federated MLP (mean ± 95% CI over 10 seeds) | 0.669 ± 0.009 | 0.489 ± 0.011 | — |
| Centralised logistic regression | 0.689 | 0.463 | 0.527 |
| Centralised random forest | 0.613 | 0.495 | 0.506 |
| Always "stress" | 0.673 | 0.402 | 0.500 |
No model beats the majority class on these subjects. Per-minute physiology barely separates stress across unseen subjects, and heart rate is missing in 53% of the minutes. The pipeline works on real data; this is a weak signal, not a bug.
Source: results/mix_swell_wesad/, topology_id 9033d1cf…, commit 3d4db6f. It is experiments/mix_swell_wesad.yaml: four fogs (two with SWELL as home, two with WESAD) on lossy links, a modular MLP with one adapter per dataset, 20 rounds, 3 seeds per α. Global model on the held-out subjects, mean ± 95% CI:
| α | SWELL macro-F1 | WESAD accuracy | WESAD macro-F1 |
|---|---|---|---|
| 0.0 (segregated) | 0.576 ± 0.048 | 0.775 ± 0.017 | 0.748 ± 0.021 |
| 0.5 | 0.578 ± 0.043 | 0.777 ± 0.008 | 0.750 ± 0.012 |
| 1.0 (mixed) | 0.584 ± 0.056 | 0.766 ± 0.030 | 0.736 ± 0.032 |
Placement has no measurable effect with three seeds. On the same subjects, centralised logistic regression gives WESAD 0.913 accuracy and 0.899 macro-F1, and the random forest gives SWELL 0.612 macro-F1. The lossy links cost the cloud 7–11 of its 20 rounds per run, which failed quorum.
Source: results/techniques_personalisation/, topology_id 9033d1cf…, commit 5c2c121. It is experiments/techniques_personalisation.yaml: SWELL + WESAD on four fogs at α = 0.5, 20 rounds, 3 seeds per technique. Macro-F1, mean ± 95% CI over the 3 seeds:
- Global columns: the global model on the test subjects.
- Edge columns: each edge's own held-out rows (the last 20% of each class) at round 20, from
edge_scores.csv.
| Technique | Global SWELL | Global WESAD | Edge, trained | Edge, personal |
|---|---|---|---|---|
| FedAvg | 0.620 ± 0.012 | 0.746 ± 0.009 | 0.641 ± 0.023 | — |
| FedPer | ¹ | ¹ | 0.632 ± 0.033 | — |
| LG-FedAvg | ¹ | ¹ | 0.694 ± 0.007 | — |
| Ditto | 0.627 ± 0.011 | 0.746 ± 0.009 | 0.634 ± 0.013 | 0.651 ± 0.004 |
| APFL | 0.625 ± 0.006 | 0.747 ± 0.018 | 0.646 ± 0.024 | 0.695 ± 0.019 |
| FedRep | ¹ | ¹ | 0.621 ± 0.021 | — |
| FedBABU | 0.610 ± 0.004 | 0.749 ± 0.009 | 0.629 ± 0.018 | — |
¹ These keep groups on the edge, so their global model is not a meaningful score; the INDEX lists the numbers.
- Who wins on the edges. APFL's personal model and LG-FedAvg are the only techniques whose edge intervals clear FedAvg's. Ditto's personal model falls in between.
- The global model is not hurt. Ditto, APFL and FedBABU keep it as good as FedAvg's.
- Edge scores are optimistic. The held-out rows are close in time to the training rows, so the edge scores are optimistic for every technique alike. They rank the techniques, but don't compare with the global test scores.
Source: results/techniques_drift/, topology_id ff55fe4d…, commit 90ef785. It is experiments/techniques_drift.yaml: SWELL + WESAD on four fogs at α = 0 (segregated), over lossless links so that lost rounds do not mix with drift. Training is SGD, 20 rounds, 3 seeds. Every technique's learning rate (0.1 for all) was chosen on the validation subjects, never on the test ones (validation.csv). Macro-F1, mean ± 95% CI:
| Technique | Global SWELL | Global WESAD | Edge, trained | Trunk alignment |
|---|---|---|---|---|
| FedAvg | 0.549 ± 0.081 | 0.752 ± 0.005 | 0.581 ± 0.040 | 0.177 ± 0.073 |
| FedProx | 0.545 ± 0.095 | 0.750 ± 0.012 | 0.584 ± 0.042 | 0.177 ± 0.075 |
| SCAFFOLD | 0.487 ± 0.072 | 0.828 ± 0.027 | 0.559 ± 0.045 | 0.171 ± 0.024 |
| FedNova | 0.561 ± 0.085 | 0.754 ± 0.014 | 0.595 ± 0.056 | 0.151 ± 0.051 |
| FedDyn | 0.593 ± 0.056 | 0.876 ± 0.010 | 0.616 ± 0.046 | 0.055 ± 0.010 |
| MOON | 0.551 ± 0.078 | 0.754 ± 0.014 | 0.575 ± 0.049 | 0.170 ± 0.063 |
- On WESAD, FedDyn and SCAFFOLD beat FedAvg in these runs; the three intervals do not overlap.
- On SWELL, no technique separates from FedAvg.
- Over lossy links (techniques_drift_lossy/) every technique loses, but not alike. The cloud loses 7–11 of 20 rounds. FedNova and FedDyn hold best on SWELL (0.510 against 0.416 for FedAvg), and FedDyn loses most of its WESAD lead (0.775).
Source: results/techniques_robustness/, topology_id ff55fe4d…, commit 30ea239. It is experiments/techniques_robustness.yaml: SWELL + WESAD on four fogs at α = 0.5, over lossless links. Every fog runs the scenario's aggregator, and 20% of each dataset's edges flip the sign of their update, x − s·(y − x). Global macro-F1, mean ± 95% CI over 3 seeds:
| Fog aggregator | SWELL, no attack | SWELL, s = 5 | WESAD, no attack | WESAD, s = 5 |
|---|---|---|---|---|
| FedAvg | 0.578 ± 0.109 | 0.244 ± 0.000 | 0.751 ± 0.000 | 0.392 ± 0.000 |
| Trimmed mean (β = 0.2) | 0.584 ± 0.066 | 0.429 ± 0.266 | 0.751 ± 0.000 | 0.316 ± 0.515 |
| Median | 0.587 ± 0.036 | 0.571 ± 0.109 | 0.763 ± 0.069 | 0.450 ± 0.963 |
| Krum (f = 2) | 0.582 ± 0.036 | 0.549 ± 0.143 | 0.743 ± 0.021 | 0.486 ± 0.768 |
| Multi-Krum (f = 2) | 0.599 ± 0.064 | 0.564 ± 0.104 | 0.788 ± 0.099 | 0.445 ± 1.006 |
| Geometric median | 0.587 ± 0.062 | 0.569 ± 0.156 | 0.751 ± 0.000 | 0.648 ± 0.752 |
| Bulyan (f = 1) | 0.587 ± 0.043 | 0.503 ± 0.280 | 0.754 ± 0.014 | 0.213 ± 0.147 |
| Norm clip (1.0) | 0.589 ± 0.059 | 0.575 ± 0.132 | 0.751 ± 0.000 | 0.777 ± 0.127 |
- The strong attack (s = 5) collapses FedAvg, and the trimmed mean does not stop it.
- In these runs, norm clipping is the most stable defence against the strong attack on both datasets. It keeps SWELL near the clean scenario and avoids the WESAD degradation seen with the other robust aggregators.
- The other robust aggregators hold SWELL, but their WESAD scores vary widely between seeds. The half-widths near 1 are correct for 3 seeds that disagree; the INDEX gives the per-seed values.
- Selection detects the strong attackers, though rescued keys let some back in. Multi-Krum drops 75% of them from its selection and fully excludes 64%. Bulyan, which picks with Krum one at a time as in its paper, runs at f = 0 in the 6-edge fogs, about half its selections.
Source: results/techniques_privacy/, commit 5995082. It is experiments/techniques_privacy.yaml: central DP at the fogs and local DP at the edges, with C = 1.0, over lossless links. ε is for δ = 1e-5, an upper bound without subsampling amplification; local DP is accounted with sensitivity 2C:
| Scenario | SWELL | WESAD | ε after 20 rounds |
|---|---|---|---|
| FedAvg (no DP) | 0.578 ± 0.109 | 0.751 ± 0.000 | — |
| Central DP, σ = 0.5 | 0.582 ± 0.074 | 0.629 ± 0.147 | 82.9 |
| Central DP, σ = 1.0 | 0.449 ± 0.120 | 0.311 ± 0.084 | 30.8 |
| Local DP, σ = 0.5 | 0.480 ± 0.150 | 0.317 ± 0.080 | 245.9 |
| Local DP, σ = 1.0 | 0.424 ± 0.072 | 0.391 ± 0.314 | 82.9 |
ε describes the mechanism with C fixed; C was calibrated on the training data without privacy, so it is not an end-to-end guarantee (the INDEX gives the adjacency and denominator). In this configuration, stronger privacy costs a lot of utility, and local DP costs much more than central DP. At the higher noise levels some edges diverge and the cloud loses rounds (up to 18 of 20 with local DP at σ = 1.0).
Source: results/techniques_server/, topology_id ff55fe4d…, commit 47c482f. It is experiments/techniques_server.yaml: the drift comparison's setup (SWELL + WESAD, segregated, lossless links, SGD at lr 0.1, 20 rounds, 3 seeds) with a different server side. Each optimizer's server rate was chosen by the final global model on the validation subjects (validation.csv). Macro-F1, mean ± 95% CI:
| Server side | Global SWELL | Global WESAD | Simulated time (s) |
|---|---|---|---|
| FedAvg | 0.549 ± 0.081 | 0.752 ± 0.005 | 21 |
| FedAvgM | 0.506 ± 0.018 | 0.754 ± 0.014 | 21 |
| FedAdam | 0.612 ± 0.038 | 0.853 ± 0.079 | 21 |
| FedYogi | 0.602 ± 0.041 | 0.875 ± 0.076 | 21 |
| FedAdagrad | 0.627 ± 0.013 | 0.898 ± 0.045 | 21 |
| FedBuff-style buffering at the fogs | 0.417 ± 0.031 | 0.749 ± 0.045 | 12 |
| FedBuff-style + FedAsync mixing rule | 0.512 ± 0.186 | 0.751 ± 0.000 | 12 |
- In these runs, FedAdagrad, FedYogi and FedAdam beat FedAvg on WESAD; the intervals do not overlap. On SWELL they overlap.
- FedBuff-style buffering finishes 20 rounds in 43% less simulated time, but its per-round score on SWELL is lower; time-to-accuracy is not compared yet. It is a hierarchical adaptation (the fogs buffer, the cloud stays synchronous), not the FedBuff or FedAsync protocol.
Source: results/infrastructure_architectures/, commit 2833a9c, infrastructure_id 90b27e16… (A–C) and a28dd1a2… (D). It is experiments/infrastructure_architectures.yaml: SWELL + WESAD, the same subjects, training (SGD at lr 0.1, chosen on validation) and 5 seeds on an illustrative, uncalibrated infrastructure, on the sim backend. A: the 28 edges straight under the cloud; B: four fogs on two fog servers; C: B with mixed devices; D: C with the SWELL fogs' server down from 30 to 60 s and nearest_alive failover. Mean ± 95% CI:
| Arm | Global SWELL | Global WESAD | Round time (s) | Run (s) | WAN (MB) |
|---|---|---|---|---|---|
| A, cloud-centralised | 0.551 ± 0.039 | 0.753 ± 0.013 | 5.48 | 109.8 | 198.4 |
| B, edge–fog–cloud | 0.552 ± 0.036 | 0.754 ± 0.012 | 1.88 | 37.7 | 28.6 |
| C, heterogeneous | 0.552 ± 0.036 | 0.754 ± 0.012 | 7.56 | 151.3 | 28.6 |
| D, dynamic | 0.544 ± 0.042 | 0.751 ± 0.014 | 7.72 | 339.1 ± 120 | 18.2 |
- The architectures tie on learning (B and C give the same model bit for bit), so they are ranked on system metrics only.
- In this model of the continuum, the fogs cut WAN traffic 7× and round time 2.9× against the centralised cloud, slow devices make every round wait (4× B's), and a fog failure costs a round deadline (120 or 300 s) although the failover moves its edges within 5 s.
- Decision fidelity between
simandlocal(the logical federations of A and B, commit76d1bd8, 5 seeds each): the final models, protocol events and bytes match bit for bit in all 10 pairs, so both backends rank A and B the same on learning (a tie, below the seeds' noise ceiling) and on traffic. They rank them in opposite order on time: the logicalsimhas no compute model and counts link hops, whilelocalmeasures this machine's training, which B spreads over four processes. C and D wait for the emulated backend (I10). Thelocalruns had first stalled on MQTT behind Docker Desktop's port forwarding, fixed by giving the transport one connection each way. The INDEX gives the details. - Sim against emulated, with a closed calibration (results/fidelity_sim_emulated/, pre-registered, calibration
b55104b3…, 6 configurations × 5 seeds on other topologies than the calibration's): the protocol matches in all 30 pairs, but the calibrated simulation is still 40–50 % too fast in round time and link latency. It still picks the emulation's best configuration on time, bytes and learning with zero regret, and ranks time with τ-b 0.87. Bytes and models differ only by floating point, yet beyond the pre-registered tolerances.
Source: results/model_families/, topology_id ff55fe4d…, config_id f81c7216… (MLP) and ab0cab7d… (transformer), commit 5b658b0. It is experiments/model_families.yaml: FedAvg's setup above, with modular_mlp against feature_transformer (one token per feature, one attention block). Each family's SGD rate was chosen on the validation subjects: 0.1 for the MLP, 0.3 for the transformer. Macro-F1, mean ± 95% CI over 3 seeds:
| Family | Parameters | Global SWELL | Global WESAD | Traffic per run |
|---|---|---|---|---|
modular_mlp |
9,378 | 0.549 ± 0.081 | 0.752 ± 0.005 | 261 MB |
feature_transformer |
3,778 | 0.534 ± 0.240 | 0.752 ± 0.014 | 105 MB |
- The two families tie on this benchmark. The transformer varies much more between seeds on SWELL (0.426 to 0.613).
- The transformer sends 60% fewer bytes, but costs 2.7 times as much compute. Only the learning rate was tuned per family.
Source: results/contribution_noise/, topology_id ff55fe4d…, commit f257ac4. It is experiments/contribution_noise.yaml: SWELL + WESAD on four fogs at α = 0.5, FedAvg, 10 rounds, 5 seeds; in each dataset a seeded 20% of the edges flip their labels or send Gaussian noise (σ = 0.01). The fogs' contribution diagnostic is compared against the known noisy edges: AUC = P(honest edge scores above a noisy one) within a fog and round, mean ± 95% CI over seeds.
| Noisy edges | Shapley AUC | Leave-one-out AUC |
|---|---|---|
| Gaussian noise | 0.772 ± 0.054 | 0.780 ± 0.052 |
| Label flipping | 0.697 ± 0.238 | 0.704 ± 0.240 |
- Edges that send noise score below the honest ones in every seed.
- Flipped labels score lower on average, but one seed of five goes the other way (AUC 0.43); the interval includes 0.5.
- Permutation importance on the val subjects (
onion_fl explain) putssnkeystrokesfirst on SWELL andbvp_0_medianfar ahead on WESAD; the INDEX lists the caveats.
Source: results/continuum_warm_start/, commit 63b0e47. A federation trained on SWELL only for 20 rounds is continued with SWELL + WESAD (learning.init: {name: run}): rounds 21–40, SWELL's frozen preprocessing, WESAD's adapter fresh. Global macro-F1, mean ± 95% CI over 3 seeds:
| Scenario | Global SWELL | Global WESAD | WESAD after 5 rounds |
|---|---|---|---|
| Parent (SWELL only, 20 rounds) | 0.464 ± 0.137 | — | — |
| Continued with WESAD (rounds 21–40) | 0.599 ± 0.041 | 0.751 ± 0.000 | 0.721 ± 0.068 |
| From scratch, 20 rounds | 0.549 ± 0.081 | 0.752 ± 0.005 | 0.326 ± 0.141 |
| From scratch, 40 rounds | 0.608 ± 0.058 | 0.769 ± 0.023 | 0.326 ± 0.141 |
- In these runs the continuation does not forget SWELL (+0.135 over its parent), and it learns WESAD much faster from the trained trunk.
- Continuing is exact. A 20-round run continued for 20 more rounds equals 40 rounds without stopping, bit for bit, in all three seeds.
Source: results/continuum_stream/, commit 64e3587. It is experiments/stream_swell_wesad.yaml:
- each training subject replays its rows in time order, after a 20-minute bootstrap;
- a round comes every 10 minutes of data, 18 in all, until the last label arrives;
- every label, or a seeded fifth of them, arrives 10 minutes late;
- each round trains only on the rows it has not used yet.
Macro-F1, mean ± 95% CI over 3 seeds:
| Labels | Prequential SWELL | Prequential WESAD | Test SWELL | Test WESAD |
|---|---|---|---|---|
| Every row | 0.783 ± 0.045 | 0.000 ± 0.000 | 0.404 ± 0.000 | 0.262 ± 0.000 |
| A fifth | 0.800 ± 0.037 | 0.000 ± 0.000 | 0.404 ± 0.000 | 0.262 ± 0.000 |
- The stream mechanics hold on the real recordings: every row arrives in order, no label trains before it arrives, and two runs of the same seed are identical.
- This is not yet continual learning.
- After its bootstrap, every WESAD window is stress, which the served model never predicts before the sessions end.
- SWELL's prequential score mostly measures persistence within its condition blocks.
- With no replay memory, the final model predicts stress for every test row. Memory is C4.
Source: results/continuum_replay/, commit ae6566f (rerun after the review of PR #172). The streams above, every row labelled, recent rows only against recent rows plus replay (a 512-row reservoir, replay_ratio 0.5, not tuned). Macro-F1, mean ± 95% CI over 3 seeds:
| Scenario | Prequential SWELL | Prequential WESAD | Test SWELL | Test WESAD |
|---|---|---|---|---|
| Recent only | 0.783 ± 0.045 | 0.000 ± 0.000 | 0.404 ± 0.000 | 0.262 ± 0.000 |
| Replay | 0.614 ± 0.085 | 0.000 ± 0.000 | 0.351 ± 0.229 | 0.305 ± 0.187 |
- Replay does not help in this setup.
- It slows the switch after a SWELL condition change, which costs a prequential score that mostly measures persistence.
- At the untuned lr 0.1, training on twice the rows makes SWELL edges diverge: 1, 69 and 3 failed trainings in the three seeds. With quorum 1.0, that costs the cloud 1, 5 and 3 rounds, and all three final global models diverge too.
- Every final model, with or without replay, predicts a single class for every test row. The test scores are the final models', scored offline.
- Recent only reproduces the stream runs above bit for bit. The benchmark (C8) chooses the learning rate and replay settings on validation.
Source: results/continuum_triggers/, commit 29bf949. The streams above, every row labelled, no replay. A continuum trigger opens the rounds: a schedule every 10 minutes (C3's pace), 300 reported rows (volume), or a prior shift detected at an edge, a fog or the cloud (drift). Mean ± 95% CI over 3 seeds:
| Trigger | Rounds | Bytes | Prequential SWELL | Prequential WESAD | Test SWELL | Test WESAD |
|---|---|---|---|---|---|---|
| Schedule | 18 | 186 MB | 0.783 ± 0.045 | 0.000 ± 0.000 | 0.404 ± 0.000 | 0.262 ± 0.000 |
| Volume | 8 | 86 MB | 0.514 ± 0.023 | 0.003 ± 0.014 | 0.404 ± 0.000 | 0.262 ± 0.000 |
| Drift | 4 | 45 MB | 0.203 ± 0.032 | 0.056 ± 0.122 | 0.404 ± 0.000 | 0.262 ± 0.000 |
- The schedule reproduces C3's runs bit for bit.
- A prior-drift trigger alone sees the switch, not what follows.
- Every SWELL edge detects its switch to the stress blocks once, and so do both SWELL fogs and then the cloud on their pooled statistics. The trigger opens rounds there, at a quarter of the bytes.
- It then opens none until the end: the model served from minute 65 predicts no stress through the stress blocks, and the classes of the arriving labels no longer change.
- C8 combines triggers (
any), with aperformancedrift or a schedule.
- Volume opens rounds when rows pile up, not at the switches, and its prequential score drops.
- Every final model predicts stress for every test row, so the test scores cannot tell the triggers apart; C8 tunes the training on validation.
Source: results/continuum_unlabelled/, commit 07fb2c8. The streams above with a tenth of the rows labelled, 10 minutes late, and nine configurations: none, pseudo_label (threshold 0.8/0.9/0.95 × weight 0.5/1.0) and consistency (noise 0.05/0.1). Mean ± 95% CI over 3 seeds:
| Configuration | Validation SWELL (final model) | Prequential SWELL | Pseudo-label accuracy SWELL | Pseudo-label accuracy WESAD |
|---|---|---|---|---|
| none | 0.400 ± 0.000 | 0.800 ± 0.019 | — | — |
| pseudo_label, best and worst | 0.400 ± 0.000 | 0.770 ± 0.014 (0.95/0.5) to 0.657 ± 0.025 (0.8/1.0) | 0.85–0.99 | 0.000 |
| consistency | 0.400 ± 0.000 | 0.797 ± 0.016 | — | — |
- A null result: no setting could be chosen. Every final model predicts stress for every validation row, so the selection ties and the test comparison was not run.
- On SWELL the pseudo-labels are accurate and still lower the prequential score, more as the threshold falls and the weight rises.
- On WESAD every accepted pseudo-label is wrong: the served model is confident in the class the stream left. The audit shows it.
- Consistency changes almost nothing. The settings C8 chooses on validation should give the selection a model that is not constant.
- These runs replace two earlier ones. In the first, the statistic measured against the history never fired. In the second, statuses in flight counted toward the next round. Both are explained in the INDEX.
Source: results/continuum_benchmark/, commit 3bb359b. The streams of the unlabelled runs (a tenth of the rows labelled, 10 minutes late, a round every 10 minutes of data), with five baselines. Each baseline's learning rate, epochs, memory and objective were chosen on the validation streams before the test runs. Mean ± 95% CI over 5 seeds, on the test subjects:
| Baseline | Prequential SWELL | Final SWELL | Final WESAD | Forgetting WESAD | SWELL recovery (rounds) | Sample-epochs |
|---|---|---|---|---|---|---|
| frozen (v0 only) | 0.319 ± 0.115 | 0.302 ± 0.092 | 0.463 ± 0.147 | 0.000 | never | 35 |
| retrain from scratch | 0.546 ± 0.096 | 0.405 ± 0.003 | 0.262 ± 0.000 | 0.318 ± 0.148 | 7.4 ± 2.3 | 12,600 |
| warm start | 0.795 ± 0.018 | 0.404 ± 0.000 | 0.262 ± 0.000 | 0.405 ± 0.243 | 3.2 ± 0.6 | 2,890 |
| warm + replay | 0.724 ± 0.024 | 0.411 ± 0.020 | 0.262 ± 0.000 | 0.458 ± 0.124 | 4.2 ± 0.6 | 3,470 |
| warm + replay + consistency | 0.728 ± 0.024 | 0.372 ± 0.088 | 0.312 ± 0.140 | 0.436 ± 0.187 | 4.2 ± 0.6 | 3,470 |
- Adaptation, not learning. Every updating baseline ends predicting one class on the test subjects. What separates them is how fast they follow SWELL's switch to the stress blocks. Warm start is the fastest, and retraining from scratch is no ceiling: it is slower and costs 4.4× the compute.
- No retention. On WESAD, whose stream is stress-only after t₀, every updating baseline forgets the other class. Frozen's better WESAD score comes from a v0 that leans to WESAD's test majority.
- Memory and unlabelled rows add nothing measurable here. At a tenth of the labels a replay memory has at most a row to replay per training, so the memory type and capacity cannot be told apart.
- Sweeps. More labels do not raise either score, while a longer label delay lowers the prequential one. A prior-drift trigger keeps most of the schedule's prequential score at 38% of its bytes.
- Champion and challenger almost never promote with these validation streams, so the served model stays the initial weights. In every run, the first trained model was rejected because no validation label had arrived yet.
Source: results/femnist_reference/, config_id befb8635…, topology_id 80d45cbe…, commit a3fa271. It is experiments/femnist_reference.yaml: the 200 fixed writers of datasets/femnist.yaml, one edge per writer, 160 training edges over four fogs and 40 test writers (8,665 images), modular_mlp on the 784 pixels, 30 rounds of one local epoch, seeds 0–2.
| Model | Accuracy | Macro-F1 |
|---|---|---|
| Federated MLP (mean ± 95% CI over 3 seeds) | 0.519 ± 0.003 | 0.153 ± 0.002 |
| Centralised logistic regression | 0.661 | 0.472 |
| Always "1" (majority class) | 0.060 | 0.002 |
The federated model clearly beats the majority class, so it is not a constant predictor; it was still improving at round 30 and stays below centralised logistic regression. results/sim_scale/ measures the simulator from 100 to 4,000 edges.
Source: results/nurse_stress/, config_id 93db2e3c…, topology_id 9d37394c…, data_id ea32ad8d…, commit 36e3a25. These are centralised baselines only; the federated runs of experiments/nurse_stress_reference.yaml have not run. The data is 15 nurses in a hospital, 63,913 E4 windows with survey labels (15% stress). Each of seeds 0–4 draws its own 4 test nurses; the table gives the mean ± 95% CI.
| Model | Accuracy | Balanced accuracy | Macro-F1 |
|---|---|---|---|
| Logistic regression | 0.851 ± 0.027 | 0.538 ± 0.065 | 0.528 ± 0.091 |
| Random forest | 0.834 ± 0.036 | 0.540 ± 0.057 | 0.533 ± 0.081 |
| Always "no stress" | 0.846 ± 0.041 | 0.500 | 0.458 ± 0.012 |
On held-out nurses the models barely beat the majority class. The field labels come from the authors' detector, rated by the nurses, so "no stress" is the time that was not flagged; the INDEX discusses what this allows.
Source: results/stress_predict_reference/, config_id 6a48914d…, data_id 4d64cbd9…, commit e8ddfd9. onion_fl baseline experiments/stress_predict_reference.yaml --models lr rf --cv 5 --seed S: subject-level 5-fold cross-validation on the 34 subjects of datasets/stress_predict.yaml (3,425 windows, 34% stress), seeds 0–4.
| Model | Macro-F1 | Balanced accuracy | Accuracy |
|---|---|---|---|
| Random forest (mean ± 95% CI over 5 seeds) | 0.568 ± 0.017 | 0.574 ± 0.014 | 0.666 ± 0.012 |
| Logistic regression | 0.521 ± 0.007 | 0.547 ± 0.004 | 0.666 ± 0.006 |
| Always "rest" (majority class) | 0.399 | 0.500 | 0.664 |
The E4 statistics carry some signal on unseen subjects, but little: accuracy is no better than always answering "rest". No federated run has been made on this dataset yet.
Source: results/stress_e4/, issue #244. The protocol was committed before the benchmark ran (commit 8bf2343), and every run's run_id, run_hash, config_id, topology_id and commit is in the INDEX. Code: commits 8bf2343–e965216, all with the same src/.
- E4 track (
benchmarks/stress.yaml,stress_lodo.yaml,stress_per_study.yaml): WESAD wrist, PhysioNet, Nurse Stress and Stress-Predict with the same 30 harmonized E4 features. One edge per subject, one fog per study (topology_id5f5cd72e…,data_ida5610d4e…). - Lab track (
benchmarks/stress_lab.yaml): SWELL and WESAD as in the existing baselines (topology_idff55fe4d…,data_id9fd57375…). It is never in the same table as the E4 track. - Protocol. The test subjects are fixed by the roles seed and are the same in every scenario and seed: E4 4 / 9 / 4 / 8 per study, lab 5 + 3. Five seeds, 20 rounds, SGD with the lr chosen beforehand on validation subjects. Macro-F1 over the test windows of each study, mean ± 95% CI; differences paired by seed. The centralised logistic regression is one number.
Macro-F1, E4 track:
| WESAD wrist | PhysioNet | Nurse | Stress-Predict | |
|---|---|---|---|---|
| Majority class | 0.391 | 0.457 | 0.459 | 0.399 |
| Centralised LR per study | 0.877 | 0.563 | 0.542 | 0.641 |
| Centralised LR pooled over the four | 0.386 | 0.550 | 0.543 | 0.459 |
| FedAvg, hierarchical, per-study adapters | 0.850 ± 0.032 | 0.495 ± 0.016 | 0.552 ± 0.014 | 0.660 ± 0.036 |
| FedAvg, hierarchical, shared adapter | 0.493 ± 0.066 | 0.556 ± 0.020 | 0.557 ± 0.010 | 0.498 ± 0.026 |
| FedBABU | 0.888 ± 0.011 | 0.603 ± 0.038 | 0.556 ± 0.015 | 0.673 ± 0.015 |
| FedDyn | 0.853 ± 0.014 | 0.640 ± 0.072 | 0.552 ± 0.044 | 0.663 ± 0.011 |
| Trained on the other three (leave-one-out, shared adapter) | 0.479 ± 0.062 | 0.558 ± 0.023 | 0.478 ± 0.031 | 0.493 ± 0.028 |
What beats the centralised regression and what doesn't:
- Plain FedAvg beats it on no E4 study. It is below the regression on WESAD wrist and PhysioNet, and its interval contains it on Nurse and Stress-Predict.
- FedBABU and FedDyn beat it on Stress-Predict and, by a small margin, on PhysioNet. FedBABU ties it on WESAD wrist.
- On Nurse nothing separates from it, and everything is close to the majority class.
- Lab track: the federation beats the regression on SWELL (FedAvg 0.555 ± 0.033 against 0.433) and loses on WESAD (best FedDyn 0.856 ± 0.048 against 0.918).
The other comparisons:
- Flat and hierarchical FedAvg give the same model (paired differences within ±0.007): with full participation and sample weights the hierarchy only changes the traffic.
- The shared adapter hands the model to Nurse, which holds 90% of the windows. WESAD wrist loses 0.357 ± 0.064 and Stress-Predict 0.162 ± 0.037. With per-study adapters, the federation beats one regression pooled over the four studies on the laboratory studies.
- Laboratory → hospital transfers poorly. Trained on the three laboratory studies, the model scores 0.478 ± 0.031 on the test nurses, against 0.557 ± 0.010 when Nurse trains (paired −0.079 ± 0.039). The majority class is at 0.459.
- Not run: the bootstrap by subject,
feature_transformer(#229), themixingscenario and the 3-class PhysioNet task. The INDEX lists them.
Source: results/heart_disease_triangle/, commit 0fa2b87. It is experiments/heart_disease_triangle.yaml: FLamby's four hospitals as clients, each tested on its official FLamby test split (254 patients), with modular_mlp (one hidden layer) trained for 50 rounds of one Adam epoch in every arm, seeds 0–4. FedAvg runs on two fogs (topology_id 6f49bb9f…, config_id 67041951…), and pooled and local-only on one fog (topology_id 54cd664e…).
| Arm (accuracy, mean ± 95% CI over 5 seeds) | Mean of the 4 hospitals | All 254 test patients |
|---|---|---|
| Pooled | 0.820 ± 0.018 | 0.795 ± 0.014 |
| Local-only, best hospital (Cleveland) | 0.704 ± 0.042 | 0.720 ± 0.015 |
| FedAvg (2 fogs) | 0.747 ± 0.039 | 0.782 ± 0.024 |
FedAvg comes close to pooled over all test patients and beats every local-only arm, but it does worse than pooled on Switzerland's 16 test patients. The local Switzerland model is a constant predictor, because all 30 of its training patients have the disease. Pooled matches FLamby's own pooled number (0.795, the mean of the per-seed files it publishes), but the protocol is not FLamby's (MLP instead of logistic regression, Adam epochs instead of SGD steps, and a hierarchical FedAvg), so the FedAvg row does not reproduce FLamby's. The INDEX has the per-hospital table and the comparison.
Source: results/cifar10_mlp/, config_id d0b01225… (Dir(0.5)) and 71c75dfc… (IID), topology_id 60f08e8d…, commit 195443d. It is experiments/cifar10_mlp.yaml: NIID-Bench's setting of 10 synthetic clients (partition_seed 0) under one server, tested on the official test batch (10,000 images), with modular_mlp on the 3,072 pixels. The schedule is NIID-Bench's, 50 rounds of 10 local epochs with SGD at lr 0.01 but without its momentum, and the seeds are 0–2.
| Model | Dir(0.5) accuracy | IID accuracy |
|---|---|---|
| Federated MLP, round 50 (mean ± 95% CI over 3 seeds) | 0.477 ± 0.003 | 0.534 ± 0.011 |
| NIID-Bench FedAvg, simple CNN (published, not run here) | 0.682 ± 0.007 | 0.704 ± 0.002 |
| Constant predictor | 0.100 | 0.100 |
This is not the NIID-Bench reproduction. The 17 to 20 point gap is the model: an MLP on raw pixels shares nothing across image positions. NIID-Bench publishes no MLP result on CIFAR-10. The CNN run, which is the ±3 point check, waits for torch_module presets (#245). The MLP overfits after about round 10: its test loss rises while its accuracy levels off.
Source: results/pilot_deadline_coupling/, pre-registered at commit b7f0f2f, runs at b7f0f2f and a0af5e3, 66 config_ids (each run's identity is in analysis.json). It is experiments/pilot_deadline_coupling.yaml in sim: FEMNIST's 160 training writers on 160 devices of three speeds, six configurations (flat, 2 fogs, 4 fogs × FedAvg, FedAdam), a 1 s deadline or FedBuff-style buffering, and five data-device couplings; time to 0.35 test accuracy.
- The noise gate passed in both regimes (split-half top-1 0.92 and 1.00), with a ceiling regret of practically zero.
- With the deadline, the factorised predictor and its FLOSS variant pick the true winner in all five cells (4 fogs, FedAdam), with or without coupling.
- In the FedBuff-style regime, both predictors pick wrongly in all five cells (regret 2.1–3.1 times the best time),
rho = 0included. The pre-registered count over the coupled cells (4 of 8) says "the gap exists", but the failure comes from the regime, not from the coupling: the predictors do not see the quorum, and FedAdam collapses under buffering. - The ex ante diagnostic does not separate the cells (AUC 0.50 for every score). The scores cannot see the regime that decides the outcome.
Source: results/continuum_distributed/, commit ab5e66f. C8's warm start behind champion/challenger, on five Docker containers reached over SSH (not real hardware: the acceptance on real machines is pending), against its simulation; means over 3 seeds. A third mode kills the process of the host of 13 SWELL edges at 60 s, starts it again 15 s later from its edges' saved state, and starts the host of 5 more edges at 90 s.
| Mode | Rounds | Prequential SWELL | Trainings | MB sent | Late updates |
|---|---|---|---|---|---|
| simulation | 18 | 0.372 | 193 | 214.5 | 0 |
| distributed | 18 | 0.352 | 186 | 208.5 | 15 |
| distributed, with failures | 18 | 0.341 | 134 | 162.2 | 26 |
| simulation, with the same failures | 18 | 0.327 | 146 | 180.9 | 0 |
- Every edge of every run trains exactly the labelled rows it trains in the simulation; the 39 restarted edges came back with their state, and no row was lost or trained twice.
- Without failures, the runs differ in round 1, where 14–16 edges miss the fogs' 6 s deadline with their first training. The failures cost the rounds without the down edges.
- The
simbackend reproduces the failures with the same keys (start_in,checkpoints,restarts,joins). Every edge trains the same rows as on the machines, but a simulated process restarts at once, while a real one took 12–16 s to rebuild its scenario, so the simulation misses fewer rounds and sends more bytes. - The final model is the initial one in every mode (no challenger is promoted, as in C8), except the simulation with failures in seed 2, which promoted one; so it says little about fidelity.
The rest of this section lists centralised baselines from the old scripts (removed in F7.2). They live in results/legacy/, whose INDEX.md gives each file's origin and caveats. Use onion_fl baseline to produce new ones on the current data layer.
Source: results/legacy/advanced_ml_results/wesad_baseline_results.json. The subjects are split 7 train / 3 val / 5 test, and the 22 wrist features give 1,057 test windows.
| Model | Test accuracy | Macro-F1 | F1 (stress class) |
|---|---|---|---|
| Random Forest | 0.828 | 0.647 | 0.393 |
| SVM | 0.780 | 0.544 | 0.215 |
| Logistic Regression | 0.770 | 0.577 | 0.292 |
Source: results/legacy/subject_cv_results/subject_cv_summary.json (2025-09-28). Values are mean ± std.
| Dataset | Model | Accuracy | Macro-F1 |
|---|---|---|---|
| WESAD | Logistic Regression | 0.865 ± 0.079 | 0.854 ± 0.087 |
| WESAD | Random Forest | 0.768 ± 0.083 | 0.738 ± 0.081 |
| SWELL (computer modality) |
Logistic Regression | 0.951 ± 0.009 | 0.946 ± 0.009 |
| SWELL (computer modality) |
Random Forest | 0.989 ± 0.006 | 0.987 ± 0.008 |
blok leak fix (commit 002246f, 2025-10-25), when an experimental-block id was used as a feature. The last report after the fix (commit 791a397, 2025-11-27; no longer in the tree) gave SWELL 0.669 ± 0.014 (LR) and 0.670 ± 0.014 (RF) accuracy.
Source: results/legacy/advanced_ml_results/swell_baseline_results.json (2025-12-04): a random 50,000-row sample, 163 features, a subject-disjoint 50/20/30 split.
| Model | Test accuracy | Macro-F1 |
|---|---|---|
| Random Forest | 0.573 | 0.572 |
| Logistic Regression | 0.544 | 0.544 |
| Linear SVM | 0.515 | 0.514 |
102 subjects and 3,927 samples. The largest class holds 0.552 of them, and every model stays at that rate: the best XGBoost reaches 0.551 ± 0.026 over subject 5-fold (results/legacy/baseline_models/sweet/training_report.json), and macro-F1 stays between 0.24 and 0.34. On those 14 features no model learns more than the class prior.
src/onion_fl/
├── core/ message, codec, node, context, topology, registry, ids
├── data/ contract, ingest (readers, steps), cache, roles, placement
├── learning/ model, sharing, aggregators (+ server optimizers), trainers (+ inits), metrics
├── roles/ coordinator, aggregator, edge; round policies; federation builder
├── runtime/ sim (virtual clock), real (wall clock), network, devices
├── transports/ memory, mqtt, grpc, flower
├── observability/ events, run, diagnostics, analysis (+ report), sinks
├── experiment/ config, sweep, runner, real (processes), cli
├── studio/ the web app: FastAPI API, dry-run previews, static single-page app
├── baselines.py LR, RF, XGBoost and subject cross-validation
└── datasets/ loaders from before the redesign, kept as the parity reference
datasets/ dataset descriptors (YAML)
topologies/ topologies (YAML)
experiments/ experiments (YAML): mix_ab, swell_reference
docker/ MQTT broker, OTEL collector, Jaeger, Prometheus, Grafana
docs/ architecture, rules, related work, diagrams, release notes, the design spec
results/legacy/ results from before the redesign, with INDEX.md
scripts/ data extraction (SWEET ZIPs) and real-sample creation
tests/ pytest suite
just check # ruff check, ruff format --check, pytest: what every PR needs
just test tests/test_roles_protocol.py
just test-cov- Style. ruff, 88 columns,
from __future__ import annotations. The ruff version is pinned inpyproject.tomland.pre-commit-config.yaml. - Rules. docs/RULES.md: real data only for anything that trains or evaluates; subject-disjoint evaluation; meta columns are never features; results cite a committed artifact.
- Workflow. Each GitHub issue gets a
task/#Nbranch fromdevelopand a PR back intodevelop;mainonly receives tagged releases. Commits followtype(scope): Imperative summary in English. The procedure and a GitHub API helper are in .claude/skills/tarea-github/SKILL.md.
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
PRs into main and manual dispatch |
ruff check, ruff format --check and pytest on Python 3.11, with an MQTT broker for the real-runtime tests |
pr-review.yml |
PRs into main |
Trivy filesystem scan |
codeql.yml |
PRs into main and manual dispatch |
CodeQL analysis |
| Dependabot | Weekly | Updates opened against develop |
The workflows run only on release PRs into main, to save CI minutes; task PRs are gated by just check locally.
- SWEET is unchecked. Its descriptor was written without the raw files, and its minute deduplication can keep a report that has no stress value. Run
onion_fl data inspect sweetand the parity test before citing results. - WESAD wrist signals are held at the label rate (700 Hz), so their statistics match the previous loader only approximately; chest signals match exactly.
- SWELL posture is not loaded: the per-minute Kinect file has every date one month early, so its rows only align with the other modalities by position.
- Global evaluation with personal or zone heads. With
fedperorzonesharing, the global model has no trained heads. Use the edge and zone scores. - Lossy links need deadlines. The simulator drops messages on lossy profiles (
wifi,4g,lora) without retransmitting them. An aggregator without adeadlinethen waits for a lost update for ever, and the run endsincomplete.onion_fl planand the Studio warn about it. - The Studio is local. It listens on
127.0.0.1over plain HTTP. Only its orders need an operator's token; everything else is open, so don't expose it. - Live control is for simulations. A real run's coordinator checks signed orders, but nothing yet delivers an operator's order over MQTT, and the Studio refuses orders to it.
- Security. MQTT runs without TLS or authentication, the broker sees every update, and there is no secure aggregation (#227). Differential privacy exists as plugins (
dp_fedavg,local_dp), with the caveats of the privacy results above. - Real-run latency across machines needs NTP-synchronised clocks.
- Manual deployments across machines need the same data and cache on each machine, because every process rebuilds the scenario.
- Compute in simulation is modelled as samples per second (or the measured wall time), not as a device profile.
- Hundreds to a few thousand edges. The simulator was measured up to 4,000 edges on one machine: time and memory grow linearly (about 11 ms per edge and round for a 113k-parameter model, and 1.2 MiB per edge), but only with the
npzcodec and without thedivergencediagnostic, which is quadratic in the children of an aggregator. With JSON links each round is 10 to 16 times slower (results/sim_scale/). - Streams before C7.
- Each round trains on the rows it has not used yet, plus a replay sample when
continualis set and an unlabelled term whenunlabelledis. Neither is tuned, and pseudo-labelled rows never enter the replay memory. - Rounds are synchronous: FedAsync and FedBuff are not yet consumption policies of a continuous federation. Only
data,priorandperformancedrift are detected. - Every edge must take part in every round:
close_at_quorumand time-based availability are outside the temporal guarantee. - A stream run cannot be continued (
init: run) or run for real yet.
- Each round trains on the rows it has not used yet, plus a replay sample when
| Milestone | Content |
|---|---|
| v0.3.0 | ✅ Onion-FL Studio: topology library and editor, run monitor, comparisons between topologies and scenarios, tutorial with dry-run previews, onion_fl serve (#103) |
| v0.4.0 | ✅ Federated learning techniques as plugins, with comparisons on real data: personalisation (#147), non-IID drift (#148), robustness, attacks and privacy (#149), server optimizers and buffering (#150) |
| v0.7.0 | In progress: continual federated learning in simulation (spec). Model bundle and continuation (#157) ✅, streams with delayed labels (#158) ✅, replay memory (#159) ✅, triggers and continuous federation (#161) ✅, semi-supervision (#160) ✅, versions, promotion and rollback (#162) ✅, and the benchmark (#163) ✅ |
| Later | gRPC (#196) and Flower (#204) transports, network emulation (#195), real distributed deployment (#199); secure aggregation and TLS (#227) |
| Period | Milestone |
|---|---|
| 2025-07 | First Flower FedAvg + Mosquitto prototype; fog-node aggregation; ECG5000 demo |
| 2025-09 | Real WESAD and SWELL loaders; real-data-only policy; ECG5000 dropped |
| 2025-10 | Meta columns (blok, …) excluded from features after the leak was found |
| 2025-11 | First working federated SWELL run; clients/, brokers/, servers/ structure |
| 2025-12 | OpenTelemetry, Jaeger, Prometheus and Grafana; SWEET baselines |
| 2026-03 / 04 | justfile; accept/strict stale-update policy; pure-function refactor |
| 2026-10 | v0.2.0: redesign into a framework (spec in docs/superpowers/specs/), old runtime removed; v0.3.0: Onion-FL Studio; v0.4.0: federated learning techniques |
Licensed under the Apache License 2.0; see LICENSE.
- Datasets: WESAD (Schmidt et al.), SWELL-KW (Koldijk et al.) and SWEET.
- Device traces: FedScale's client capacity and availability traces (Lai et al., 2022; Apache-2.0).
- Software: PyTorch, Eclipse Mosquitto, paho-mqtt, OpenTelemetry, Prometheus, Grafana, Jaeger and pydantic.