Skip to content
mgillrPublic

About

Train one machine-learning model across many parties who don't trust each other. A dishonest minority can't skew the result, and every party computes byte-for-byte identical output on any CPU or architecture (big-endian included) — so each round ships a receipt anyone can re-check offline and reproduce exactly. Rust; drop-in for Flower.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

ACFA

Deterministic Byzantine-robust aggregation for federated learning and distributed systems, with verifiable receipts.

arXiv

Aggregate vectors from mutually distrusting parties. Get the same bytes on every machine, and a receipt any third party can re-check offline.

use acfa_aggregate::{krum_aggregate, Contribution};

let agg = krum_aggregate(&contributions, 1)?;  // f = 1 tolerated adversaries

How it works

Mutually distrusting parties -- each on whatever hardware they run -- submit signed updates. ACFA combines them with a robust rule so an adversarial minority cannot move the result, computes the aggregate as an exact function of the input set, and emits a receipt. Anyone, on any architecture, can re-run that receipt offline and get the identical bytes -- so the answer to "who moved the aggregate" is checkable rather than asserted.

flowchart LR
  P1["Party 1 (x86_64)"]
  P2["Party 2 (ARM64)"]
  P3["Party 3 (big-endian)"]
  PA["Adversary, up to f of n"]
  P1 --> AGG
  P2 --> AGG
  P3 --> AGG
  PA -. poison attempt .-> AGG
  AGG["ACFA robust aggregation<br/>integer fixed-point (Q16.16)<br/>Krum / Bulyan / median / trimmed mean"]
  AGG --> R["Aggregate + receipt<br/>byte-identical, signed"]
  R --> V["Any third party, any architecture<br/>re-checks the receipt offline"]
  V --> OK["Same bytes = verified:<br/>what was aggregated, and who equivocated"]
Loading

What it solves

Robust aggregation rules (multi-Krum, Bulyan, coordinate median, trimmed mean) already exist in most federated learning frameworks. They bound how far a minority of adversaries can move your result. They do not tell you who moved it.

That gap is arithmetic, not cryptography. Float aggregation is order-dependent: sum the same updates in a different order and you get different bytes. So when two servers report different aggregates, you cannot distinguish a faulty server from a lying participant. The honest case is already ambiguous, so no accountability layer can sit on top of it.

ACFA computes the aggregate in integer fixed point (Q16.16), making it an exact function of the input set. After that, any disagreement is a difference in inputs or in conduct.

Identical glibc 2.41, x86_64 vs aarch64, 600,000 samples:

divergences
raw libm (exp, cos, ln) 293 (~1 in 2,047), each exactly 1 ULP
same values through the Q16.16 boundary 0

The second row is not evidence that the boundary absorbed the first, and the guarantee does not rest on it. On this corpus the right-hand column reads 0 whether or not quantisation absorbs anything: an encoding changes only if the scaled value lies within the divergence of a rounding boundary, the closest sample sat 20,472 ULPs away, and 0 of 600,000 were close enough for a 1-ULP difference to change the quantised result. One expected flip would need about 6.9e10 samples, roughly 114,000x this corpus. Absorption is also the wrong word for what would happen: at a rounding boundary a 1-ULP divergence is not absorbed but amplified to a full Q16.16 unit -- rare and total rather than damped. Measured through the shipped kernel, on two doubles one ULP apart that straddle a boundary:

0x3edfffffffffffff   scaled 0.49999999999999994   ->  ok 0
0x3ee0000000000000   scaled 0.5                   ->  ok 1

A one-ULP difference in, a full Q16.16 unit out. So the table's juxtaposition invites the reader toward the opposite of the mechanism.

What the two rows do show is the thing worth showing: raw libm differs across architectures, and these 293 differences did not reach the aggregate here. The guarantee itself comes from construction, not from this null result -- the aggregate is computed in integer fixed point, so it is an exact function of the input set however the inputs were produced.

Numbers and method in build/DETERMINISM-RESULTS.md; the smaller first probe that motivated it is in research/xarch-libm-divergence.md.

CI blocks any push where eight architectures fail to produce a byte-identical receipt: x86_64 Linux, aarch64 Linux, Apple Silicon and x86_64 Windows on real hardware, plus i386 and armv7 (32-bit), ppc64le, and s390x (big-endian) under emulation.

The big-endian row is the one that matters. A machine that lays out integers the other way round is where byte-identity breaks if it is going to. x86_64 little-endian and s390x big-endian produce the same SHA-256 over the full five-scenario fingerprint, 4664c321388267507c825b8e1b5ef6c2c082879bb871d2c0fff557d514b2fedf. Method and reproduction in build/ARCHITECTURE-COVERAGE.md.

flowchart TB
  IN["One input set<br/>(order-independent)"]
  IN --> A1["x86_64 Linux"]
  IN --> A2["aarch64 / Apple Silicon"]
  IN --> A3["Windows x86_64"]
  IN --> A4["i386 / armv7 (32-bit)"]
  IN --> A5["ppc64le"]
  IN --> A6["s390x (big-endian)"]
  A1 --> H["Identical SHA-256 receipt<br/>4664c321...14b2fedf"]
  A2 --> H
  A3 --> H
  A4 --> H
  A5 --> H
  A6 --> H
  H --> CI["CI blocks any push where<br/>the 8 targets disagree"]
Loading

Releases

Releases are signed annotated git tags vMAJOR.MINOR.PATCH on main, each with a matching GitHub Release. From v0.3.0 the same version appears in all three Rust crate manifests and in the Python adapter, so Cargo.toml identifies the release; before v0.3.0 it did not, and CHANGELOG.md records that caveat rather than quietly correcting it.

Version Date What it is
v0.4.0 unreleased Closes the cross-context forgery (#79): the signed preimage now binds the context and node id (ACFA-R1 → ACFA-R2). First release in which the fingerprint moves; v1 receipts still decode and verify
v0.3.0 2026-08-20 Ships the paper's Lemma 12 no-flip certificate; redacted receipts (zero-plaintext audit artefact); a caller-supplied verifier work budget; round retirement to conviction witnesses
v0.2.0 2026-08-19 First production-signed release; full audit closed; adapter published to PyPI
v0.1.0 2026-08-17 First public tag

v0.4.0 IS NOT RELEASED. The crate manifests declare 0.4.0 and the section above describes it, but no v0.4.0 tag exists on the remote — git ls-remote --tags returns v0.1.0, v0.2.0, v0.3.0. A build of current main is therefore not a released artefact and cannot be verified as one by the process described above. Tracked as #100.

To identify a build, check the fingerprint, not the version string.

cd build/layer2-receipt && cargo run -q --release --example digest

The comparable digest is 4664c321388267507c825b8e1b5ef6c2c082879bb871d2c0fff557d514b2fedf on every supported architecture, including big-endian s390x. It changes only on a deliberate, documented wire-version bump, and has done so exactly once: v0.1.0–v0.3.0 printed bd13ba3209a940b2025368a63c546ffd59e2580a1b8aa7128cc9b423d1957e40, and v0.4.0 moved it by binding the context and node id into the signed preimage (ACFA-R1 → ACFA-R2). A value matching neither means you are not running a released tree.

Full history, including what is still open at each release: CHANGELOG.md. What the format promises, and what it does not: COMPATIBILITY.md.

Install

The Rust crates are not published to crates.io -- install them from this repository (below). The Python adapter is published to PyPI as acfa-flower.

Rust 1.87+ (is_multiple_of). Layer 1 has zero dependencies.

The CLI tools, no checkout needed:

cargo install --git https://github.com/mgillr/acfa-rs acfa-aggregate   # acfa-agg
cargo install --git https://github.com/mgillr/acfa-rs acfa-receipt     # acfa-verify

As a dependency. Cargo finds each crate by name inside the repository, so git alone is correct here; adding path alongside it is a manifest error:

[dependencies]
acfa-aggregate = { git = "https://github.com/mgillr/acfa-rs" }
acfa-receipt   = { git = "https://github.com/mgillr/acfa-rs" }
acfa-finality  = { git = "https://github.com/mgillr/acfa-rs" }

Python (from PyPI):

pip install acfa-flower               # the adapter + numpy
pip install "acfa-flower[flower]"     # add the Flower strategy (pulls flwr)

The adapter shells out to acfa-agg for the byte-identical kernel, so install the CLI too (the cargo install above) and it is found on PATH; set ACFA_AGG_BIN to point at a specific binary. There is no pure-Python fallback: a second implementation could silently disagree, which is the failure the fixed-point kernel exists to remove.

To install the adapter straight from the repository instead (e.g. an unreleased commit):

pip install "git+https://github.com/mgillr/acfa-rs#subdirectory=adapters/flower"

Quickstart

git clone https://github.com/mgillr/acfa-rs
cd acfa-rs/build/layer2-receipt

cargo run -q --release --example issue -- --pki > trusted.pki
cargo run -q --release --example issue           > receipt.acfa
cargo run -q --release --bin acfa-verify -- receipt.acfa --pki trusted.pki --f 1
VERIFIED
  rule         Krum
               NOT PINNED -- verified against the receipt's OWN claimed rule; pass --rule to require the rule you expect
  context      0000000000000000000000000000000000000000000000000000000000000000
               NOT PINNED -- verified against the receipt's OWN claimed context; pass --ctx to require the event you expect
  frac bits    16
  round        1
  state root   31a584d8788f4b52cf47b4db208a96098e71ff5152306d34ff2ae42929562071
  output root  8d43057fcae20ef0a765604b6a212225236b6d25cc51452b8a15a2a24ffc9a14
  aggregate    3 values, first [4, 2, 5]
  admitted     [1, 2, 3, 4, 5]
  convicted    []
  bound n>=req met (5 admitted, 5 required) -- POPULATION only, not a safety verdict
  no-flip      NOT CERTIFIED -- the selection boundary is too close
               margin 0 <= threshold 392 (4 x beta 98)
               The boundary is an EXACT TIE. No amount of extra
               resolution closes it -- the irreducible residual.
               This is NOT evidence of a flip. It means quantisation
               COULD have changed who was selected, and the margin
               condition cannot rule it out. An exact tie (margin 0)
               is the residual no condition can ever cover.

Checked against the identities in trusted.pki and f = 1.
This establishes that the issuer computed honestly over the set shown.
It does NOT establish that the issuer showed every entry it held --
compare the state root against an independently obtained one for that --
pass it as --expect-state-root <64-hex> and a mismatch exits 1.

Meeting the population bound is NOT a safety guarantee. A colluding
within-norm adversary can be selected while the bound holds and move
the aggregate; that is a property of the imported rule. See the
within-norm characterisation in the aggregation crate's test suite.

That block is the tool's real stdout, pasted from a run of the three commands above, not a summary of it. Three things in it are easy to misread as errors and are not. rule and context say NOT PINNED because you passed neither --rule nor --ctx: the verifier checked the receipt against the rule and event the receipt ITSELF claims, which is self-consistency, and pinning them is how you turn that into a check against what you expected. no-flip says NOT CERTIFIED because this example's five contributions put the Krum selection boundary at an exact tie (margin 0), and no margin condition can clear a tie -- it is not evidence that a flip happened. The exit code is still 0.

The roots are fixed, not per-run: the issue example mints its keys from a seed, so regenerating trusted.pki and receipt.acfa reproduces the same receipt and the same two roots. Measured over three regenerations here.

--pki is required. A receipt carries its own identity set, so checking it against itself proves nothing: mint five keys and every signature in the resulting receipt is valid.

cargo run -q --release --example issue -- --forged-pki > forged.acfa

cargo run -q --release --bin acfa-verify -- forged.acfa --pki trusted.pki --f 1  # exit 1
cargo run -q --release --bin acfa-verify -- forged.acfa                          # exit 3

Exit codes: 0 verified, 1 invalid, 2 unparseable, 3 self-consistent only.

API

Rust

use acfa_aggregate::{krum_aggregate, bulyan_aggregate, coord_median_trim, Contribution};

let cs = vec![
    Contribution { tie_key: b"node-1".to_vec(), v: vec![65536, 131072] },  // Q16.16
    Contribution { tie_key: b"node-2".to_vec(), v: vec![65600, 131000] },
];

let agg = krum_aggregate(&cs, 1)?;        // needs n >= 2f+3
let agg = bulyan_aggregate(&cs, 1)?;      // needs n >= 4f+3, refuses below

tie_key breaks exact score ties. It must be stable per contributor and is never interpreted. Out-of-range and non-finite values are refused, not saturated.

Averaging floors, so the aggregate is biased DOWNWARD and the bias accumulates. Every rule that averages loses a fraction of an LSB per round, always in the same direction: (n-1)/2n LSB, measured at 400 trials per row against the closed form (n=2: -0.231, n=5: -0.396, n=16: -0.472). Round-to-nearest would cancel over many rounds; floor does not, so over training it drifts linearly rather than averaging out. This is deliberate and not tunable here -- the rounding rule is wire contract, because the vendored Python reference floors (//) and byte-identity with it is the property this kernel exists to provide. Round-half-to-even would be both deterministic and unbiased, and changing to it costs the reference pin. Sizes and the drift-over-rounds table: adapters/flower/README.md.

Did fixed-point arithmetic change WHO was selected? Byte-identity says every replica computed the same selection; it does not say that selection is the one un-quantised gradients would have produced. multi_krum_certified answers that per round, checkably:

use acfa_aggregate::multi_krum_certified;

let (selection, cert) = multi_krum_certified(&cs, 1)?;   // same selection as multi_krum
if let Some(c) = cert {
    println!("no-flip certified: {} (margin {} vs {})", c.certified, c.margin, c.threshold);
}

This is Lemma 12 of the paper in its observable form. In raw Q16.16 units the grid step is exactly 1, so it reduces to integer arithmetic -- delta_star = 2*l1_max + 3*d, beta = (n-f-2)*delta_star, certified iff margin > 4*beta -- no float anywhere, so two replicas that agree on the selection agree on the certificate bit for bit.

Sound, and in the regime that matters it fires rarely. It can decline to certify a stable configuration but can never certify an unstable one, and an adversary inflating a contribution enlarges beta, making certification harder -- hostile input withholds a certificate and cannot forge one. But beta is a worst case assuming every one of the d coordinates errs maximally and in the worst direction, so at high dimension with clustered contributions -- the realistic federated case -- the measured certification rate is approximately zero. A tighter probabilistic rule is open work. None means no certificate is available (empty round, kernel refusal, the select-all band, or Bulyan) and is not the same as "not certified".

Two tracked limits: the shipped 4*beta is twice what soundness requires (a direct argument on the observed gap gives 2*beta; the looser constant ships because the code must not enforce a threshold the published lemma does not state), and because l1_max is a global maximum one large-magnitude outlier raises beta for the whole round and can deny certification to everybody -- availability-only, never forgeable (#72).

Receipts:

use acfa_receipt::{Receipt, Policy, Rule, State};

let receipt = Receipt::issue(&state, ctx, round, &pki, f, Rule::Krum);
let bytes   = acfa_receipt::encode_checked(&receipt)?;  // refuses an f the wire cannot carry

// Verification needs a policy you obtained independently, not the receipt's own.
let verified = receipt.verify(&Policy::new(trusted_pki, f))?;
println!("{:?} admitted, {:?} convicted", verified.admitted, verified.convicted);

Verification is bounded by a work budget, and it is fail-closed. Verifying does work proportional to the total tensor coordinates carried, and neither the contribution-count cap nor the derivable-proof bound bounds that product -- measured, a 32 MiB receipt bought 16 s of CPU and returned Ok with every guard passing. Policy carries a coordinate budget defaulting to DEFAULT_MAX_VERIFY_COORDINATES, and the refusal names the count it declined, so the number to raise is in the error:

let policy = Policy::new(trusted_pki, f).with_max_coordinates(4_000_000);

check_self_consistent takes the same default -- diagnosis is still a door that accepts a file from a stranger.

Redacted receipts: full accountability, zero plaintext. A receipt carries every participant's raw update, which is what makes it re-executable and also why it cannot be shown to anyone the participants do not already trust with their gradients. redact() drops the vectors and keeps everything that authenticates or commits:

let redacted = receipt.redact();
let bytes    = acfa_receipt::wire::encode_redacted(&redacted);   // distinct magic, ACFA-X2
let v        = redacted.verify(&Policy::new(trusted_pki, f))?;   // same admitted set, same convictions

Lossless for verification, because a signature is over contrib_msg(ctx, params, rnd, node_id, tensor_hash) and a leaf hashes the tensor hash, never the tensor -- and EquivProof was already plaintext-free. So authentication, the state root, admission and conviction all survive at full strength with the same answers. What it cannot do is re-execute the aggregate, which genuinely needs the vectors.

This is redaction. It is NOT secure aggregation and NOT differential privacy. No formal privacy guarantee: it withholds vectors from the artefact while the aggregator saw everything, and tensor_hash is a binding commitment, not a hiding one, so a recipient who can guess a plausible update confirms it by hashing. A fixed 32-byte hash replaces 4 + 8d tensor bytes, so the artefact shrinks only for d >= 4.

Retiring settled rounds. State::merge bounds the live contribution set, so a replica that never retires anything reaches that bound as a function of elapsed rounds and then fails every later merge permanently. prune_through(round) retires a round's contributions to conviction witnesses -- (ctx, params, rnd, node_id, tensor_hash, sig), about 149 bytes -- exactly enough to still detect and still prove equivocation, so detection strength is unchanged rather than traded. It bounds the constant, not the growth: retention is still linear in rounds.

Python (Flower)

from acfa_flower import AcfaStrategy, Rule

strategy = AcfaStrategy(rule=Rule.KRUM, f=1, min_fit_clients=5)

Drop-in for FedAvg as wiring. Sampling, config and evaluation are inherited. num_examples is ignored: FedAvg weights by it, it is an unverifiable self-report, and weighting a robust rule by it hands back the guarantee.

It is not a drop-in for FedAvg's BEHAVIOUR on non-IID data. Every robust rule here selects by distance from the other clients, and a client whose data comes from a different distribution is far from the majority for the same reason an attacker is -- the rule cannot tell them apart. Measured with zero adversaries, one client drawn from N(3,1) and the rest from N(0,1), KRUM retains 3% / -0% / 0% of that client's proportional share at 10 / 20 / 40 clients, against MEAN's ~100% control; the effect does not wash out as the cohort grows. That is inherent to distance-based robust aggregation rather than a defect here, but it changes who the model learns from. Full table and the milder rules: adapters/flower/README.md.

This adapter suits models up to roughly a hundred thousand parameters. Values cross to the kernel as hex ASCII over stdin -- a structural 2.13x wire cost, arithmetic on the format rather than something to tune away. Measured at 30.9 ms per 1,000 parameters with n=10 clients, that extrapolates to ~6 minutes per round for ResNet-18 (11.7M params) and ~57 minutes for BERT-base (110M), and the cost grows with the client count too. The Rust kernel itself is fast and the determinism guarantee is unaffected -- this is the text boundary, not the aggregation. Above that size, call the kernel directly instead of through this adapter. Measurements: adapters/flower/README.md.

Direct call:

from acfa_flower import aggregate, Rule

result = aggregate(client_updates, rule=Rule.KRUM, f=1, tie_keys=client_ids)

Any language

$ printf 'rule krum\nf 1\n01 3ff0000000000000\n02 3ff199999999999a\n03 3ff3333333333333\n04 3ff4cccccccccccd\n05 4024000000000000\n' | acfa-agg
ok 75366

Values cross as IEEE-754 bits, output is Q16.16. rule is one of krum, bulyan, median, trimmed, mean.

Distributed systems properties

Relevant if you are building on async gossip with no coordinator:

Order invariance. The aggregate is a function of the multiset, not the delivery sequence. Replicas that received the same contributions in different orders, with duplicates, compute identical bytes. No agreement protocol needed to get agreement on the result.

Content-addressed state. Contributions and equivocation proofs are a grow-only product CRDT -- an OR-Set crossed with a G-Set -- merging by union over Merkle-addressed leaves. Merge is commutative, associative and idempotent, so the state converges without ordering.

Attribution. An identity that signs two different values for the same round produces a self-authenticating proof. Any node holding the PKI verifies it offline, with no quorum and no appeal to who reported it. Honest nodes derive the proof themselves on observing both halves, so suppression does not work.

Fail-visible finality (acfa-finality). A round certificate is f+1 signatures over the admitted membership, equivocation cut and aggregate root, with membership pinned by authenticated broadcast rather than a wall clock. If the synchrony bound breaks, or the round budget is under-provisioned below 2 tau while it holds, two disjoint honest groups can each certify a different cut. At n >= 3f+2 that requires no Byzantine participant, so nobody is attributable and a naive design fails silently. Here the fork is the evidence: two valid conflicting certificates cannot coexist under the assumption, so their coexistence proves it broke. Nodes halt, publish the pair, reconcile from the last uniquely-certified round.

Use cases

Every command below was run against this tree.

Federated learning with an audit trail. Sites train locally, share only updates, raw data never moves. Later a regulator asks which contributions produced the deployed model; the receipt answers from the artefact alone.

cd build/layer1-aggregate && cargo run -q --release --example uc1_poisoned_fl
hosp-E   [-45.0, 60.0, -30.0]   <-- compromised

FedAvg (plain mean)   [-8.278, +12.318, -5.916]
multi-Krum (ACFA)     [+0.905, +0.395, +0.105]

Multi-party model merging. Several organisations merge deltas and each must sign off on one result. Each reproduces the same bytes independently.

cd build/layer2-receipt && cargo run -q --release --example uc2_merge_order

Reproducible robustness research. New aggregation rules are compared across papers whose implementations quietly disagree. Here the Python reference and the Rust production code are held to byte-identical agreement in CI, so a new rule ships with golden vectors and the claim is machine-checked.

python3 build/layer2-receipt/tests/golden/generate_l2.py | diff - build/layer2-receipt/tests/golden/vectors_l2.json && echo identical

Cross-language pipelines. A Python researcher produces an aggregate a Rust deployment reproduces exactly, because both are held to the same vectors.

Fork diagnosis. When a decentralised run ends in two conflicting states, acfa-finality gives the round it happened in and the two signed certificates proving it, including the case where no participant misbehaved and timing simply broke.

Offline audit. An auditor with no access to any participant re-derives the figure from the receipt. No network, no clock, no live system.

Not implemented here: merging receipts across independently operated instances, and paying for verified contributions on-chain. Both follow from the primitives; neither is in this repository.

Performance

GitHub runner, single-threaded, cargo run --release --example scale:

participants dimension receipt verify
10 100 9.8 KiB 1.5 ms
100 100 90 KiB 21 ms
25 10,000 2.0 MiB 111 ms

Verify costs roughly what issue costs: it re-executes the rule instead of checking a claimed answer. Multi-Krum is O(n^2 d), so participant count dominates, not dimension.

Limitations

  • A valid receipt proves honest computation over the set it showed you. It does not prove the issuer showed you everything it held. Compare the state root against an independently obtained one for that: acfa-verify --expect-state-root <64-hex> performs the comparison and exits 1 on a mismatch. This is the only check that addresses withholding -- a withheld entry leaves no trace in the receipt that omits it, so the root has to come from elsewhere.
  • n >= 2f+3 is a population bound, not a safety guarantee. At n=10, f=3, where it holds, a colluding adversary near the honest mean is selected in 30/30 trials and moves the aggregate 1.74x past the honest floor while staying inside the honest spread. Bulyan does not help (1.56x vs Krum's 1.61x at n=15) and refuses below n >= 4f+3; coord_median_trim is worse at 2.21x. Property of the imported rules. The field is named population_bound_met accordingly. See build/layer1-aggregate/tests/within_norm.rs.
  • The Lemma 12 no-flip certificate can be denied by a single participant, though never forged by one. beta is scaled by the largest pairwise L1 distance in the admitted set, so one contribution with large raw magnitude enlarges the threshold for the whole round and can push every configuration out of the certified tier -- for everybody, not just itself. This is availability-only: inflating that distance can only make certification harder, so a hostile input withholds a certificate and can never produce a false one. The obvious fix -- scoping the maximum to the pairs that actually enter the boundary scores -- is unsound, because a Krum score is a minimum over m-subsets and the perturbation is free to change which pairs those are, so the bound must cover every candidate pair. Documented rather than patched, tracked as issue #72.
  • The shipped certification threshold is the paper's 4*beta_hat, which is twice what soundness requires: a direct argument on the observed gap gives 2*beta_hat, verified and searched over 1.78M preimages in the contested band with zero flips. The looser constant ships because the code must not enforce a threshold the published lemma does not state; the tightening is an erratum candidate against the paper. How much it would actually gain is distribution-dependent and was measured, not assumed: between 1.00x and 11.75x depending on the margin distribution, and NO GAIN AT ALL (0.0% -> 0.0%) in the realistic high-dimensional clustered case. An earlier version of this note said it "doubles" the certification rate; three people repeated that and none of us had measured it.
  • Q16.16 fixes range at +/-2^15, resolution 2^-16. Out-of-range values are refused, not saturated, because saturation would make the result depend on which replica saturated first. Rescale upstream with a factor both parties hold.
  • wire::encode is infallible by signature and writes the fault bound f as a u32, so a usize beyond 2^32 truncates silently and the bytes decode to a different receipt. Use wire::encode_checked, which refuses instead. encode is kept unchanged because 33 call sites depend on its signature; closing that is an API decision, not a defect fix.
  • Sybil resistance is delegated to the PKI.
  • Krum is Euclidean and admits coordinate-concentrated attacks inside the honest spread. Bulyan defends that shape specifically, at n >= 4f+3.
  • No independent security review. Three bugs were found and fixed during development: a receipt that verified against its own carried identity set, an unbounded allocation from an attacker-controlled length prefix, and a default tie-break key derived from arrival order. See SECURITY.md.

Paper

Byzantine Accountability Without Consensus (arXiv:2607.10305, July 2026). Copy in paper/acfa.pdf, citation metadata in CITATION.cff.

The reference implementation is vendored at reference/acfa.py with its hash pinned. CI regenerates both layers' golden vectors from it and requires byte-identity, so the Rust is checked against a second, independently written implementation.

One divergence is deliberate, and it is worth stating precisely because it changes the selected SET, not merely a count. The reference's Bulyan stage-1 loop draws at most n-f-2 candidates while theta = n-2f; those differ exactly when f < 2, so at f in {0, 1} the reference stops one or two candidates short of its own theta. A shorter selection is not just fewer indices -- it is a DIFFERENT candidate set feeding stage 2, so at f = 1 this implementation and a paper-faithful clamped selection admit different members, not only a different number of them (measured to differ on a substantial fraction of random f = 1 trials). This implementation refuses below n >= 4f+3 and otherwise draws exactly theta; the suite asserts the divergence and asserts it is still present, so a corrected reference fails CI. No result in the paper is affected: its one Bulyan experiment runs at n = 16, f = 3, where the two agree, re-run unmodified to its published numbers.

Layout

build/layer1-aggregate   aggregation rules, fixed point, zero deps, acfa-agg binary
build/layer2-receipt     signatures, equivocation proofs, wire format, acfa-verify binary
build/layer2-finality    round certificates, deadline cut, halt-and-reconcile
adapters/flower          Flower strategy
reference/               the paper's reference implementation, hash-pinned

Layer 1 decides what a set of vectors aggregates to. Layer 2 decides who is in the set, and passes contribution leaves to Layer 1 as an opaque tie-break key. The coupling runs one way, so Layer 1 is usable alone.

Contributing

CONTRIBUTING.md. Claims carry the command that produced them and its output.

Licence

Apache-2.0. No patents filed or planned -- and the licence's section 3 patent grant makes that binding on every contributor rather than a statement of intent.

acfa-aggregate has no dependencies. acfa-receipt and acfa-finality use Ed25519 via ed25519-dalek, curve25519-dalek and subtle, which are BSD-3-Clause: their notices are reproduced in NOTICE, which you must keep when redistributing a built binary. Nothing in the transitive tree is copyleft.


This repository

build/layer1-aggregate    the deterministic kernel, zero dependencies
build/layer2-receipt      contribution set, equivocation proofs, receipts, acfa-verify
build/layer2-finality     round certificates, deadline cut, halt-and-reconcile
adapters/flower           Flower strategy, shells out to acfa-agg for identical bytes
reference/acfa.py         the paper's kernel, vendored and hash-pinned
paper/acfa.pdf            arXiv:2607.10305
tools/                    checks that hold the documentation to the code

Everything here is checked on every push, and you can run the same checks:

cargo test --release                          # in any of the three build/ crates
python3 tools/readme-commands.py              # runs every command this file documents
python3 tools/coverage-claim-check.py         # holds the coverage table to the workflow
shasum -a 256 -c SHA256SUMS                   # in reference/, pins the vendored kernel

The first two exist because a reader runs the commands before they run anything else. tools/readme-commands.py executes every command this file documents against a real git remote: the dependency block as an actual manifest, both cargo install lines, the pip install, and the quickstart's exit codes, plus a check that every path a documented command touches is present. tools/coverage-claim-check.py does the same for the architecture table, requiring each row it claims to appear in the workflow. Documentation that does not work is a build failure here, not a bug report.

History starts at the first commit of this tree. Working notes and screening material are not part of the release and are not carried over.

About

Train one machine-learning model across many parties who don't trust each other. A dishonest minority can't skew the result, and every party computes byte-for-byte identical output on any CPU or architecture (big-endian included) — so each round ships a receipt anyone can re-check offline and reproduce exactly. Rust; drop-in for Flower.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages