The Blind Machine

Polygenic Score Aggregate

Public-weighted score aggregate (additive BFV, plaintext-scalar multiply).

Repository Files

Browse the package root. signed/ is the digest-verified payload; root docs and tests/ ship as review support.

1.0.0 · sha256:413d0b16227c…b3b2aa68

polygenic_score_aggregate

16 files · 59.7 KB — package root with signed payload and support files

applications/polygenic_score_aggregate

Name Size
signed/ signed payload
tests/ support tests
129 Bytes
README.md support doc
10.1 KB
SECURITY.md support doc
4.93 KB

polygenic_score_aggregate — Blind Machine curated protocol

tenseal-BFV, minimal (additive-tier) params. The clearest “looks
multiplicative, remains additive” example: a public-weighted polygenic score
Σ_j w_j·g_j computed as an additive homomorphic fold plus one
plaintext-scalar multiply
by the PUBLIC effect-weight vector — never a
ciphertext × ciphertext multiply. See docs/protocol_catalog.md §4.

What it computes

Each contributor holds an alt-allele dosage vector g ∈ {0,1,2}^L over the
same fixed, published coordinate definition as the flagship (ordered variants
(chrom,pos,ref,alt)), missing calls encoded as 0. The protocol also fixes a
public effect-weight vector w ∈ ℝ^L, integer-scaled by a published
fixed-point factor S (w_scaled[j] = round(w_j·S)) and folded into the bundle
digest. The cohort aggregate released is the per-coordinate public-weighted
sum:

weighted_counts[j] = w_scaled[j] · Σ_i g_i[j]        (integer, exact in Z_t)
cohort_pgs_scaled  = Σ_j weighted_counts[j]          (integer)
mean_PGS           = cohort_pgs_scaled / (S·N)       (real, post-decrypt)

where each contributor’s polygenic score is PGS_i = Σ_j (w_scaled[j]/S)·g_ij.

Why it stays additive. Every weight is public, so the server applies them
as a ciphertext × plaintext multiply, which does not raise ciphertext degree
(no relinearization) and involves no rotation (no Galois keys). The
cross-coordinate reduction Σ_j is done post-decrypt in the CLI, never under
encryption — that is what keeps the protocol on the same minimal BFV params as
the flagship. This is the catalog’s canonical demonstration that a statistic can
look multiplicative and still be served by additive-tier BFV.

Exactness: BFV is exact in Z_t; the integer-scaled aggregate equals the
cleartext sum bit-for-bit (tolerance: 0). The released real values carry
the fixed-point resolution of S (per-weight rounding error ≤ 1/S).

Append-1 sentinel (one subtlety this protocol adds): encryption appends a
trailing 1 slot to every contribution, so the homomorphic sum’s last slot is
N. The server’s plaintext-weight multiply uses weight 1 on that sentinel
slot
, so it is left unscaled and still decrypts to exactly N. It is an
integrity/corruption check, not a MAC (see SECURITY.md).

BFV parameters

Params are unchanged from the flagship except the plaintext modulus (which must
grow to fit the weight-inflated value envelope) and the coeff-modulus chain
(selected by --security, below):

param value why
poly_modulus_degree 8192 8192 slots ≫ L+1; FIXED across all security levels (batching prime valid only at N=8192, depth-0 needs no bump).
plain_modulus 1073692673 30-bit batching prime (≡ 1 mod 16384). Max slot after the weight multiply is max_j(w_scaled[j])·2N; the flagship’s 20-bit t is under-sized. Exact for S=1000, w_scaled ≤ 2000, N up to ~250k. FIXED — a function of the value envelope, not security.
coeff_mod_bit_sizes see --security selects the q-band that fixes the achieved HE security level.
relin / Galois keys none ciphertext × plaintext only; no rotation

--security {128,192,256} (default 128)

--security is the ONLY knob that varies with the requested HE security level;
it selects the coeff_mod_bit_sizes chain. At fixed N=8192 the security
level is the q-band: smaller Σbits ⇒ more secure, so certifying a higher
level spends a smaller modulus. This is correct RLWE behaviour, not a bug — the
depth-0 noise floor for this payload sits in the 256 band, so 128/192 carry
surplus modulus (bigger/slower ciphertexts) than 256 (the “256 is cheaper than
128” inversion).

--security coeff_mod_bit_sizes Σ bits achieved
128 (default) [60, 60, 60] 180 128
192 [50, 50, 50] 150 192
256 [45, 45, 28] 118 256

Why 3-prime chains (not 2). TenSEAL reserves the LAST coeff prime as a
key-switching special prime, so the effective ciphertext modulus = Σ(all but
last). This protocol’s 30-bit t combined with the ciphertext × plaintext weight
multiply (scale up to ~2000) needs an effective q ≳ 80 bits, so a 2-prime
chain (e.g. [60,60], effective 60) FAILS to decrypt; every level ships a
3-prime chain (effective 120 / 100 / 90). All four additive protocols standardize
on these same PGS-safe chains so the SECURITY table is byte-identical across
bundles. The security benchmark column is computed by the harness as the
strictest level whose HomomorphicEncryption.org cap the chain fits under —
never read back from SEAL (SEAL only validates at tc128). Every level
decrypts the public-weighted aggregate bit-exact (verified, TenSEAL 0.3.16).

Downstream stages ts.context_from(...) the serialized context, so the chain
flows through unchanged — they stay security-agnostic.

Public weights (deterministic, content-addressed)

manifest.yml declares weights: { scale: 1000, values: { kind: synthetic_weights, seed: blind-v1-pgs-weights, range: [1, 2000] } }.
30_compute_encrypted.py’s scaled_weights(length) regenerates the exact
integer weight vector from that seed (stable random.Random(seed)), so the
server and the cleartext oracle score every contributor against the identical
vector, and any change to the seed/scale/generator changes the protocol digest.
No separate weight file is shipped (per docs/protocol_structure.md: public
weights live in the manifest for synthetic v1 protocols).

Stage lifecycle & I/O contract

The author’s logic lives in three pure-function files, grouped by role: server.py
(compute, the only server-side function), local_project_owner.py
(keygen/decrypt/decode), and local_data_owner.py (encode/encrypt) —
these are what sibling tests/ import. The six numbered files are materialized into signed/ at run time and are
kit-owned shims (thin argparse wrappers; do not edit) that map each stage’s CLI
(python NN_*.py --help) onto those functions, keeping the lifecycle visible
without opening a subdirectory.

stage runs trust in → out I/O
00_keygen.py local (researcher) — → PRIVATE + PUBLIC context --out-dir DIR [--security {128,192,256}]secret_context.tenseal (never upload), public_context.tenseal (uploadable)
10_encode.py local (data owner) RAW → ENCODED --raw raw.json --length L --out encoded.json (validate {0,1,2}, null→0, pad to L — identical to flagship)
20_encrypt.py local (data owner) ENCODED → ENCRYPTED --context public_context.tenseal --encoded encoded.json --out cipher.bin (appends sentinel, BFV-encrypts)
30_compute_encrypted.py SERVER ENCRYPTED → ENCRYPTED --context public_context.tenseal --inputs c0.bin c1.bin … --out result.bin (homomorphic sum then public plaintext-weight multiply; no secret key present)
40_decrypt.py local (researcher) ENCRYPTED → PRIVATE --context secret_context.tenseal --result result.bin --out plain.json (length L+1)
50_decode.py local (researcher) PRIVATE → RELEASED --plain plain.json --length L [--scale S] --out result.json (splits sentinel→N, weighted counts, cohort/mean PGS)

Inter-stage formats: contexts and ciphertexts are TenSEAL’s raw serialized bytes
(binary); raw/encoded/plain are JSON int lists; the released result is JSON with
n_contributors, weighted_counts, cohort_pgs_scaled, cohort_pgs_sum,
mean_pgs.

server.py’s compute is written once against an abstract evaluator E
(zero/add/scalar_mul), so docs/simulation_mode.md’s cleartext correctness
oracle swaps a PlaintextEvaluator for the same compute and cannot drift from
this encrypted path. Determinism gives verify-by-re-execution: the same ordered
ciphertexts in → a bit-identical result digest out (compute is deterministic;
encryption is not).

Run the full loop by hand

cd protocols/polygenic_score_aggregate
D=/tmp/pgs && mkdir -p "$D"
R() { (cd signed && uv --project env run python "$@"); }

R 00_keygen.py --out-dir "$D"
for i in 00 01 02 03; do
  R 10_encode.py  --raw ../tests/vectors/contributor_$i.json --length 16 --out "$D/enc_$i.json"
  R 20_encrypt.py --context "$D/public_context.tenseal" --encoded "$D/enc_$i.json" --out "$D/c_$i.bin"
done
R 30_compute_encrypted.py --context "$D/public_context.tenseal" \
  --inputs "$D/c_00.bin" "$D/c_01.bin" "$D/c_02.bin" "$D/c_03.bin" --out "$D/result.bin"
R 40_decrypt.py --context "$D/secret_context.tenseal" --result "$D/result.bin" --out "$D/plain.json"
R 50_decode.py  --plain "$D/plain.json" --length 16 --out "$D/result.json"
cat "$D/result.json"

Test (local-loop equivalence)

uv --project signed/env run --group dev python -m pytest tests/

Proves keygen → encode → encrypt (≥3 synthetic contributors) → compute → decrypt
→ decode equals the cleartext oracle (same public weights) bit-exact on the
integer-scaled aggregate
, and the sentinel decrypts to exactly N despite
the weight multiply (weight 1 on the sentinel slot), including that dropping one
upload yields N−1 and removes exactly that contributor’s weighted dosage. A
parametrized case (test_bit_exact_at_every_security_level) re-runs the full
public-weighted loop at --security 128, 192, and 256, asserting bit-exact
correctness and sentinel==N under each coeff-modulus chain, and that each chain’s
achieved security equals the requested level. Skips with a clear reason only if
TenSEAL cannot be imported.

Coordinate & weight definition & synthetic data

For the synthetic v1 demo both the L=1000 coordinate list and the public
weight vector are generated deterministically from manifest.yml seeds
(input.coordinates.seed, weights.values.seed). The invariant that matters is
that every contributor encodes against the same published coordinate
definition and is scored against the same published weights, and that both
are folded into the bundle SHA-256. All data here is synthetic integer/real
vectors; no real genomic data is used anywhere.

Published Versions

1.0.0

aggregate only published July 09, 2026

Application digest — content address of the signed payload

413d0b16227c861c1b1c24fa6d119013e0a0e3eb58ab573397995076b3b2aa68

Environment lock — the sealed, pinned dependency set

170d7c7717666bd71adabb3923cd60eafb425a3e2253a18901769795e44179ec
Minimum N
20
Runs per project
1
Ed25519 signature
4293f34418f664ae75fb9a7d9301badc35b18...

Manifest, coordinate definition, and params travel inside the signed payload — inspect them from the CLI (the digest above pins them all):

blind applications install polygenic_score_aggregate@413d0b16227c861c1b1c24fa6d119013e0a0e3eb58ab573397995076b3b2aa68
blind explain polygenic_score_aggregate@413d0b16227c861c1b1c24fa6d119013e0a0e3eb58ab573397995076b3b2aa68

View pinned repository · Version metadata (JSON) · the same record the CLI reads

v1

Public-weighted score aggregate (additive BFV, plaintext-scalar multiply).

aggregate only published July 06, 2026

Application digest — content address of the signed payload

fe4cd2a384cc2255e22a34e967ef6da9679fda944cb6da8fbeb1d2074aed2743

Environment lock — the sealed, pinned dependency set

170d7c7717666bd71adabb3923cd60eafb425a3e2253a18901769795e44179ec
Minimum N
20
Runs per project
1
Ed25519 signature
745438e3d71ac296d519fe521309dd767dd7c...

Manifest, coordinate definition, and params travel inside the signed payload — inspect them from the CLI (the digest above pins them all):

blind applications install polygenic_score_aggregate@fe4cd2a384cc2255e22a34e967ef6da9679fda944cb6da8fbeb1d2074aed2743
blind explain polygenic_score_aggregate@fe4cd2a384cc2255e22a34e967ef6da9679fda944cb6da8fbeb1d2074aed2743

View repository status · Version metadata (JSON) · the same record the CLI reads

Keyboard shortcuts

  • Cmd/Ctrl+K
    Focus global search
  • ?
    Open keyboard shortcuts

Send feedback

We'll only use this to respond to your feedback.