The Blind Machine

Polygenic Score Aggregate

1.0.0 Ed25519 signed

Application digest — content address of the signed/ payload

413d0b16227c861c1b1c24fa6d119013e0a0e3eb58ab573397995076b3b2aa68

186 lines · 7.93 KB · sha256:bea85a3c1c8d…60598215

Raw
#!/usr/bin/env python3"""server.py — the BLIND computation for `polygenic_score_aggregate`.This is the ONLY code that runs server-side. The kit shim`30_compute_encrypted.py` is its sole caller: it maps the argparse CLI(`--context/--inputs/--out`) onto the reserved ``compute`` function below. Theauthor writes only this pure, bytes-in/bytes-out function; all the argv/fileplumbing lives in the (kit-owned) shim.Trust boundary, made structural by the signature:    compute(inputs: list[bytes], public_context: bytes) -> bytesThere is NO secret-context parameter — this function is incapable of receiving asecret key. It runs in the network-isolated sandbox (`--network none`, read-onlyfs, non-root, resource-limited) with the PUBLIC context and ciphertexts only, sothe server never sees a single plaintext genotype.Homomorphically sum every contributor ciphertext, then apply the PUBLIC effectweights as a **plaintext-scalar multiply** — producing the cohort's weightedper-coordinate aggregate as one ciphertext.Why this stays in the ADDITIVE tier (no relin, no Galois)--------------------------------------------------------The weights are PUBLIC (they live in `manifest.yml` and are regenerated here fromthe published seed), so weighting is a ciphertext x **plaintext** multiply, notciphertext x ciphertext. A plaintext multiply does not raise ciphertext degree,so no relinearization keys are needed; there is no cross-slot rotation, so noGalois keys are needed. The cohort sum ``Sigma_j`` (a cross-coordinate reduction)is done POST-decrypt (local_project_owner.decode), never under encryption.Compute design (multiply once, at the end)------------------------------------------    acc     = Sigma_i  cipher_g_i                 # additive fold (flagship path)    result  = acc * w_plain                       # single plaintext-scalar multiplySo ``result[j] = w_scaled[j] * Sigma_i g_ij``. Written ONCE against an abstractevaluator ``E`` (the cleartext oracle in docs/simulation_mode.md swaps a plaintextevaluator into the same ``aggregate`` so it cannot drift from this encrypted path):    aggregate(inputs, E, weights):   # E provides zero / add / scalar_mul        acc = <fold inputs under E.add>        return E.scalar_mul(acc, weights)Sentinel invariant (the one subtlety this protocol adds)--------------------------------------------------------The append-1 sentinel occupies slot L of every contribution, so ``acc``'s slot Lis exactly N. The weight applied to that slot is **1** (``w_plain[L] == 1``), sothe multiply leaves the sentinel untouched: ``result``'s slot L still decrypts toexactly N. Only the L coordinate slots are scaled by the effect weights.Public weights are DETERMINISTIC + content-addressed----------------------------------------------------``scaled_weights(length)`` regenerates the published integer-scaled weight vectorfrom the fixed seed ``blind-v1-pgs-weights`` and scale ``S = 1000`` — the samevalues `manifest.yml` declares. Because server.py is covered by the bundleSHA-256, changing the seed, scale, or generator changes the protocol identity.Determinism / verify-by-re-execution: BFV add and plaintext-multiply are bothdeterministic, so the same ordered inputs always yield the same result ciphertextbytes (encryption is randomized, the *compute* is not)."""from __future__ import annotationsimport randomfrom typing import Iterable, Protocol# ---------------------------------------------------------------------------# Published public effect weights (part of the protocol identity via the digest).# Declared in manifest.yml as { scale: 1000, values: { kind: synthetic_weights,# seed: blind-v1-pgs-weights } }; regenerated here so the server (and the oracle)# apply the exact same integer-scaled vector every contributor was scored against.# ---------------------------------------------------------------------------WEIGHT_SEED = "blind-v1-pgs-weights"WEIGHT_SCALE = 1000            # published fixed-point factor S; real error <= 1/SMAX_SCALED_WEIGHT = 2000       # w_scaled in [1, 2000] => real effect weight in (0, 2.0]SENTINEL_WEIGHT = 1            # slot L weight; keeps the sentinel == N after multiplydef scaled_weights(    length: int, scale: int = WEIGHT_SCALE, seed: str = WEIGHT_SEED) -> list[int]:    """Return the published integer-scaled effect weights ``w_scaled in Z^length``.    Deterministic from ``seed`` (``random.Random`` seeds a str via SHA-512, stable    across CPython 3.x), so a reviewer regenerates the exact weight vector from the    published ``(seed, scale, length)``. ``scale`` is carried for provenance; the    integer weights are already scaled by it.    """    rng = random.Random(seed)    return [rng.randint(1, MAX_SCALED_WEIGHT) for _ in range(length)]def weight_plaintext(length: int) -> list[int]:    """Length-``length + 1`` plaintext multiplier: effect weights + sentinel weight.    The trailing ``SENTINEL_WEIGHT`` (== 1) multiplies the append-1 sentinel slot,    so slot L stays exactly N after the plaintext-scalar multiply.    """    return scaled_weights(length) + [SENTINEL_WEIGHT]class Evaluator(Protocol):    """The abstract op interface both engines implement (see simulation_mode)."""    def zero(self, length: int): ...    def add(self, a, b): ...    def scalar_mul(self, a, plain_vector): ...class BFVEvaluator:    """The real (encrypted) evaluator: ops on TenSEAL BFV ciphertexts."""    def __init__(self, context) -> None:        self.context = context    def zero(self, length: int):        import tenseal as ts        return ts.bfv_vector(self.context, [0] * length)    def add(self, a, b):        return a + b    def scalar_mul(self, a, plain_vector):        # ciphertext x PLAINTEXT vector, element-wise. Does not raise ciphertext        # degree -> no relinearization; no rotation -> no Galois keys.        return a * plain_vector    def load(self, blob: bytes):        import tenseal as ts        return ts.bfv_vector_from(self.context, blob)def aggregate(    inputs: Iterable,    evaluator: Evaluator,    weights: list[int],    length: int | None = None,):    """Fold ``inputs`` under ``evaluator.add``, then apply the public ``weights``.    Folds from the first input (no length needed). If ``inputs`` is empty a zero    vector of ``length`` is used (requires ``length``). The final step is a single    ``evaluator.scalar_mul`` by the public plaintext weight vector.    """    iterator = iter(inputs)    try:        accumulator = next(iterator)    except StopIteration:        if length is None:            raise ValueError("aggregate() needs a length when there are no inputs")        accumulator = evaluator.zero(length)    else:        for item in iterator:            accumulator = evaluator.add(accumulator, item)    return evaluator.scalar_mul(accumulator, weights)def compute(inputs: list[bytes], public_context: bytes) -> bytes:    """RESERVED blind entrypoint — sum, then public-plaintext-weight the cohort.    Deserialize the PUBLIC context and each ciphertext, fold them, apply the    published integer weight vector as a plaintext-scalar multiply, and return the    serialized result. No secret key is present; defensively refuse a context that    carries one.    """    import tenseal as ts    context = ts.context_from(public_context)    if context.is_private():        # The server must never receive a secret key.        raise ValueError("compute stage received a context holding a secret key")    if not inputs:        raise ValueError("compute stage received no ciphertexts")    evaluator = BFVEvaluator(context)    vectors = [evaluator.load(blob) for blob in inputs]    # The ciphertexts are L coordinates + 1 sentinel slot; recover L to build the    # matching public weight vector (no extra CLI arg — weights come from the seed).    length = vectors[0].size() - 1    weights = weight_plaintext(length)    result = aggregate(vectors, evaluator, weights, length=length + 1)    return result.serialize()

Inside signed payload digest 413d0b16227c…b3b2aa68. Change one byte here and the application becomes a different application.

Keyboard shortcuts

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

Send feedback

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