9f030d1c07b87761e9aed5ca7214a5cac8ed3f18cde2c71d8032bcbfb1a3a953
cohort_histogram — Blind Machine curated protocol
tenseal-BFV, minimal (additive-only) params — the exact same additive
circuit as the flagship, pointed at a non-genomic aggregate. Each
contributor is one-hot over a fixed, published bucket definition; the cohort
result is the per-bucket count vector. It exists to prove the additive
primitive is generic: genomics is the demo domain, not the only one.
Seedocs/protocol_catalog.md§3.
What it computes
Each contributor falls into exactly one bucket of a fixed, published,
ordered bucket definition (the v1 demo uses demographic age bands
["0-9", "10-19", …, "90+"], B = 10). They map their raw value to a bucket
index per that definition and encode it as a one-hot vector h ∈ {0,1}^B with
sum(h) = 1. The cohort aggregate released is the per-bucket sum:
counts[b] = Σ_i h_i[b] (integer, exact) — the histogram
sum(counts) == N (one-hot invariant; cross-checks the sentinel)
Exactness: BFV is exact in Z_t with plaintext modulus t > max bucket count. Since each contribution is one-hot, the largest bucket count is N, so
t = 1032193 is exact for N up to ~1M. tolerance: 0 — the encrypted
histogram equals the cleartext histogram bit-for-bit at every security level.
HE security level (--security {128,192,256})
00_keygen.py accepts --security {128,192,256} (default 128, matching the
prior behaviour). It is the ONLY security-dependent knob: it selects the
coeff_mod_bit_sizes chain (the RLWE ciphertext modulus). poly_modulus_degree
(N=8192) and plain_modulus (t=1032193) are fixed — they are functions of the
value envelope and the depth-0 additive circuit, not of security. The choice flows
downstream automatically because stages 20/40 context_from(...) the serialized
context; no other stage changes.
--security |
coeff_mod_bit_sizes |
Σ bits | achieved (N=8192 band) |
|---|---|---|---|
128 (default) |
[60, 60, 60] |
180 | 128 — band [153, 218] |
192 |
[50, 50, 50] |
150 | 192 — band [119, 152] |
256 |
[45, 45, 28] |
118 | 256 — band [≤118] |
At fixed N the security level is the q-band: a smaller Σ is more secure.
So — the intentional inversion — the 128-bit chain uses a larger coeff
modulus (Σ=180, bigger/slower ciphertexts) than the 256-bit chain (Σ=118).
The depth-0 additive noise floor for a bounded-int histogram sits deep in the 256
band, so certifying 128/192 spends surplus modulus. This is correct RLWE
behaviour, not a bug; all three decrypt bit-exact. The benchmark’s security
column is computed by the harness as achieved(N, Σ) = strictest level whose cap covers Σ, never read back from SEAL (SEAL always validates at tc128).
These 3-prime chains are byte-identical across all four additive protocols
(afc / carrier / cohort / pgs) so the SECURITY table stays one shared contract.
The tests/ local-loop proves bit-exactness (and achieved == requested) at all
three levels.
Append-1 sentinel: encryption appends a trailing 1 slot to every
contribution, so the homomorphic sum’s last slot decrypts to exactly N, the
contributor count. For a one-hot protocol this is doubly strong: the first B
slots must also total N, so decode asserts sum(counts) == sentinel — a free
integrity cross-check the flagship’s dosage vectors don’t have. It is an
integrity/corruption check, not a MAC — it says nothing about whether
contributors are distinct or genuine (see SECURITY.md).
Reuse relationship to the flagship
cohort_histogram is a near-free clone of allele_frequency_count: 00_keygen,
20_encrypt, 30_compute_encrypted, and 40_decrypt are the flagship’s
additive stages verbatim (same BFV params, same append-1 sentinel, same abstract
zero/add evaluator fold, same is_private() server guard). Only two stages
change:
10_encode.py— index → one-hot (a contributor holds a single bucket index in
[0, B); encode builds[0]*Bwith one1, and rejects an out-of-range or
missing bucket — a one-hot histogram has no “missing → 0” escape hatch).50_decode.py— emits{counts, n_contributors}, drops the frequency/×2
denominator, and adds thesum(counts) == Nintegrity assertion.
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 B --out encoded.json (raw is a single bucket index in [0,B); emits one-hot) |
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; no secret key present) |
40_decrypt.py |
local (researcher) | ENCRYPTED → PRIVATE | --context secret_context.tenseal --result result.bin --out plain.json (length B+1) |
50_decode.py |
local (researcher) | PRIVATE → RELEASED | --plain plain.json --length B --out result.json (splits sentinel→N, counts; asserts sum(counts)==N) |
Inter-stage formats: contexts and ciphertexts are TenSEAL’s raw serialized bytes
(binary); raw.json is a single JSON integer (bucket index); encoded/plain
are JSON int lists; the released result is JSON with n_contributors,
buckets_length, counts. Bucket labels are not re-emitted — the ordered
bucket definition lives in the digest-folded manifest.yml; the consumer maps
count position b to its published label.
server.py’s compute is written once against an abstract evaluator E
(zero/add), 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/cohort_histogram
D=/tmp/ch && mkdir -p "$D"
R() { (cd signed && uv --project env run python "$@"); }
R 00_keygen.py --out-dir "$D" # add --security 192 or 256 to certify a stronger level
for i in 00 01 02 03 04; do
R 10_encode.py --raw ../tests/vectors/contributor_$i.json --length 10 --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_*.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 10 --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 histogram exactly, that the sentinel decrypts to
exactly N (including that dropping one upload yields N−1 and decrements that
contributor’s bucket), and that the one-hot integrity check (sum(counts) == N)
rejects a tampered aggregate. Skips with a clear reason only if TenSEAL cannot be
imported.
Bucket definition & synthetic data
For the synthetic v1 demo the B = 10 bucket definition (ordered age bands) is
enumerated in manifest.yml’s input.buckets.labels. The invariant that matters
is that every contributor encodes their membership against the same published,
ordered definition and that definition is folded into the bundle SHA-256. All
data here is synthetic bucket indices; no real personal data is used anywhere.
Packaged support file for application digest 9f030d1c07b8…b1a3a953. It ships in the archive for review, but is outside the signed payload digest.