Architecture
OpenMed separates into two planes. The learning plane produces models from data that never leaves its institution. The trust plane decides which models are admitted, records what happened, and makes that record checkable by someone who trusts none of the participants.
Two planes
| Plane | Packages | Answers |
|---|---|---|
| Learning | trustfed.data, trustfed.models, trustfed.federated, trustfed.aggregation, trustfed.attack, trustfed.benchmark |
How do sites train one model together when a minority of them may be malicious? |
| Trust | trustfed.attestation, trustfed.ledger, trustfed.registry, trustfed.certification, trustfed.quality, trustfed.incentives |
Which models are admitted, on whose authority, and how does an outsider verify that later? |
The planes meet at two points only: the attestation gate that admits an update into aggregation, and the model bundle that carries a trained model into the registry. Everything else is independent, which is why a real TEE backend or a different federated framework can be swapped in without touching the rest.
End-to-end flow
site-local data ──▶ Client.train()
│ update + attestation quote
▼
Attestor.check() ──▶ rejected (unapproved code / replayed nonce*)
│ accepted
▼
aggregation (krum / median / trimmed mean / clipping)
│ new global model
▼
ModelBundle ──▶ ModelRegistry.publish()
│ │ event written to ledger
│ ▼
│ LineageGraph (parent ◀── derived)
▼
QualityAnalyzer.analyze() ──▶ QualityReport (JSON + Markdown)
│
▼
CertificationAuthority: k-of-n signatures across DISTINCT institutions
│ decision appended (tamper-evident)
▼
certified base model ──▶ CreditLedger (citable id, attribution)
│
▼
ReciprocityPolicy gates the next evaluation
* replayed-nonce rejection requires a NonceStore and require_nonce=True; both off by default
Federated co-training built
Server runs rounds: broadcast the global model, collect
Updates, reject anything that fails attestation, aggregate the survivors
with a Byzantine-robust rule, evaluate on held-out data, and record a
RoundResult.
Aggregators in trustfed.aggregation, each with a documented tolerance bound:
| Rule | Byzantine tolerance |
|---|---|
fedavg | None — a single large update dominates. Included as the baseline that shows why the others are needed. |
coordinate_median | Fewer than half the clients |
trimmed_mean | Up to the trimmed fraction on each side |
krum / multi_krum | Requires n > 2f + 2 |
norm_clipped_mean, centered_clipping | Bounds each client's influence rather than excluding clients |
Call tolerance_bound() or tolerance_table() to get the bound
for a configuration rather than assuming it. Client selection is
pluggable: RandomSelector, LossBasedSelector, and
ReputationSelector, which consumes per-client history
(ClientHistory) accumulated across rounds.
trustfed.attack exists so defenses are tested against something real:
sign flipping, gaussian noise, scaling and model replacement, label flipping, feature
corruption, and AdaptiveAdversary — an attacker that targets the specific
aggregator in use. A defense that only survives the non-adaptive attacks has not been
tested.
Attestation built
Before an update is aggregated, the client presents a Quote over its code
measurement. AttestationPolicy holds the set of approved measurements;
NonceStore enforces freshness so a captured quote cannot be replayed —
when the attestor is built with one and the policy sets
require_nonce=True. Both default to off, including in the
shipped parkinson demo, and without them a captured quote replays indefinitely.
MockSoftwareAttestor is not a security boundary. The
"measurement" is a hash of a declared identity string and the "quote" is an HMAC
under a shared key standing in for a hardware root of trust. It is sufficient to
exercise the protocol and to test accept/reject logic, and nothing more. Implement
the Attestor interface against SGX/TDX or SEV-SNP quoting APIs for a
real deployment — the rest of OpenMed is unchanged.
Tamper-evident ledger built
An append-only hash chain. Each Block carries its index, the previous
block's hash, a canonical-JSON payload, the payload hash, and a signature.
LedgerBackend.verify_chain() returns a structured ChainVerification that
detects modification and reordering always. Truncation — the case naive
implementations miss — is detected only against an anchor:
FileLedger's .head.json sidecar, or an external
Checkpoint passed as expected=. An unanchored verdict
reports anchored=False and cannot rule out a missing tail;
InMemoryLedger has no anchor.
InMemoryLedgerfor tests and short-lived runs;FileLedger(JSONL) for durable logs.- Signing is
Ed25519Signerwhencryptographyis importable, falling back toHmacSigner.HAVE_CRYPTOGRAPHYtells you which you got. The HMAC fallback provides integrity under a shared secret — it is not a signature and does not give non-repudiation. LedgerBackendis an ABC, so a permissioned-ledger backend can be added without changing callers.
The registry, certification authority, and credit ledger all write through this interface, which is what makes their histories jointly checkable.
Model registry built
A ModelBundle is the unit of exchange: a WeightsRef, a
ModelCard (following the Mitchell et al. sections), a
PipelineAttestation, an EvaluationReport, and a
FineTuningManual. Bundle IDs are content-addressed, so the identifier
commits to the contents.
Publishing a model derived from another auto-links it to its parent. The registry
therefore accumulates a LineageGraph rather than a flat file list, and
ModelRegistry.verify_lineage() walks the chain to a root and returns a
LineageVerdict naming each LineageIssue it found — a missing
parent, an unverifiable attestation, a broken hash. One call answers "is this model's
whole ancestry attested?" (It is a method, ModelRegistry.verify_lineage(),
not a free function.)
Multi-party certification built
A model becomes a certified base only when reviewers from more than one institution
sign off. ThresholdPolicy enforces k-of-n over distinct
institutions, with the rules that make it meaningful:
- A site's signature does not count toward its own submission's quorum —
ConflictOfInterestError. - Reviewer keys are institution-bound via
ReviewerKeyring; an unknown key is rejected rather than ignored. - Every decision is appended to the ledger. A revocation is a new entry; the original stays visible.
A CertificationCase moves through an explicit state machine —
submitted → under_review → blocked | certified → revoked, with
remediated as the path back from blocked. Illegal
transitions raise InvalidTransitionError rather than silently succeeding;
transitions_table() prints the whole machine.
validate_manual() holds the fine-tuning manual to a fixed schema —
intended use, data, preprocessing, hyperparameters, failure modes, clinical caveats —
so what a hospital receives is a clinically actionable model rather than a checkpoint.
Automated quality analysis built
QualityAnalyzer runs independent Checks over a bundle and
emits a QualityReport (JSON and Markdown). Shipped checks:
| Check | Catches |
|---|---|
ModelCardCompletenessCheck | Missing required model-card sections |
MetadataSchemaCheck | Structurally invalid or malformed bundle metadata |
MetricsPresentCheck | Absent, out-of-range, or implausible reported metrics |
SubgroupGapCheck | Performance gaps across reported subgroups |
MembershipInferenceCheck | Training-data leakage, via a global-threshold attack on confidence scores. Skips unless member/non-member scores are supplied; a pass means this attack failed, not that the model is private |
ParameterNormCheck | Parameter-norm outliers relative to the family |
LineageAttestationCheck | Missing or unverifiable provenance |
Summarisation goes through the LLMSummarizer protocol.
TemplateSummarizer is the default: deterministic, no model required, so
the gate runs anywhere. LocalLLMSummarizer is optional and uses a locally
hosted endpoint, so no submission leaves the reviewing site. The report carries a
DISCLAIMER stating that automated analysis supplements human review
rather than replacing it.
Credit and reciprocity built
CreditLedger records CreditEvents on top of the ledger:
accepted models, evaluations served, code contributions. It issues a
CitableIdentifier and ReleaseAttribution for accepted work,
and computes ContributorStanding — a StandingTier that maps
to governance weight.
ReciprocityPolicy is the rule with teeth: to receive a multi-site
evaluation, a site must have served as an evaluator. A request that does not qualify
returns a ReciprocityDecision carrying a machine-readable reason, not a
bare refusal — the requester is told exactly what would make it succeed.
Benchmark harness built
A Scenario declares sites, Byzantine count, attack, aggregator, rounds,
and seed. Runner executes a grid() of them and produces a
BenchmarkReport serialisable to JSON with a Markdown table.
PROVENANCE records what produced a report, and
compare_reports() diffs two runs.
The point is that a contributed defense is measured on the same scenarios as everything else, so results are comparable rather than self-reported. Runs are reproducible from the seed.
Extension points
These are the interfaces to implement if you want to extend OpenMed rather than fork it:
| Interface | Implement to add |
|---|---|
Attestor | A real TEE backend (SGX/TDX, SEV-SNP) |
LedgerBackend | A permissioned or distributed ledger |
Signer / Verifier | An HSM or external key-management system |
| Aggregator callable | A new robust aggregation rule (register it in AGGREGATORS) |
Selector | A new client-selection strategy |
Check | A new automated quality gate |
LLMSummarizer | A different local summarisation model |
| Attack callable | A new attack for the benchmark harness |
Adding an aggregator or an attack and opening a pull request against the benchmark harness is the shortest path from a research idea to something other people can reproduce. See community.